@neoline/hostbridge 2.0.2 → 2.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "HostBridge MCP server for persistent allowlisted SSH and shell sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hostbridge"
7
- version = "2.0.2"
7
+ version = "2.0.3"
8
8
  description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -8,7 +8,7 @@ import sys
8
8
  from typing import NoReturn
9
9
 
10
10
  __all__ = ["__version__", "ensure_runtime"]
11
- __version__ = "2.0.2"
11
+ __version__ = "2.0.3"
12
12
 
13
13
  # (import_name, pip_name) pairs for runtime dependency probing.
14
14
  _REQUIRED_DEPENDENCIES = (
@@ -60,7 +60,7 @@ def _version_diagnostic() -> DiagnosticResult:
60
60
  "npm": npm.get("version"),
61
61
  "python": pyproject.get("project", {}).get("version"),
62
62
  }
63
- status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.2" else "fail"
63
+ status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.3" else "fail"
64
64
  return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
65
65
 
66
66
 
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import base64
5
+ import binascii
4
6
  import json
5
7
  import posixpath
6
8
  import re
@@ -9,6 +11,9 @@ import sys
9
11
  import tempfile
10
12
  import threading
11
13
  import time
14
+ import uuid
15
+ from collections import deque
16
+ from collections.abc import Awaitable
12
17
  from contextlib import suppress
13
18
  from dataclasses import dataclass, field
14
19
  from functools import partial
@@ -34,6 +39,61 @@ STDIN_READING_COMMANDS = [
34
39
  re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
35
40
  ]
36
41
  MAX_IDLE_BACKEND_SESSIONS = 4
42
+ TUNNEL_FRAME_BYTES = 2048
43
+ TUNNEL_READ_BYTES = 32 * 1024
44
+ TUNNEL_MAX_BUFFER_BYTES = 128 * 1024
45
+
46
+ _REMOTE_TUNNEL_SCRIPT = r"""
47
+ import base64
48
+ import os
49
+ import socket
50
+ import sys
51
+ import threading
52
+
53
+ host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
54
+ write_lock = threading.Lock()
55
+
56
+ def emit(kind, payload=b""):
57
+ suffix = ":" + base64.b64encode(payload).decode("ascii") if payload else ""
58
+ with write_lock:
59
+ print(prefix + ":" + kind + suffix, flush=True)
60
+
61
+ try:
62
+ connection = socket.create_connection((host, port), timeout=15)
63
+ connection.settimeout(None)
64
+ except Exception as exc:
65
+ emit("ERROR", str(exc).encode("utf-8", errors="replace"))
66
+ raise SystemExit(1)
67
+
68
+ emit("READY")
69
+
70
+ def receive():
71
+ try:
72
+ while True:
73
+ data = connection.recv(2048)
74
+ if not data:
75
+ emit("EOF")
76
+ os._exit(0)
77
+ emit("DATA", data)
78
+ except Exception as exc:
79
+ emit("ERROR", str(exc).encode("utf-8", errors="replace"))
80
+ os._exit(1)
81
+
82
+ receiver = threading.Thread(target=receive, daemon=True)
83
+ receiver.start()
84
+ try:
85
+ for raw_line in sys.stdin.buffer:
86
+ line = raw_line.strip()
87
+ if line == (prefix + ":EOF").encode("ascii"):
88
+ connection.shutdown(socket.SHUT_WR)
89
+ receiver.join()
90
+ break
91
+ data_prefix = (prefix + ":DATA:").encode("ascii")
92
+ if line.startswith(data_prefix):
93
+ connection.sendall(base64.b64decode(line[len(data_prefix):], validate=True))
94
+ finally:
95
+ connection.close()
96
+ """
37
97
 
38
98
 
39
99
  def _to_bytes(chunk: str | bytes) -> bytes:
@@ -47,8 +107,9 @@ def _command_reads_stdin(command: str | None) -> bool:
47
107
 
48
108
 
49
109
  class HostBridgeSSHServer(asyncssh.SSHServer):
50
- def __init__(self, authorized_public_key: str) -> None:
110
+ def __init__(self, authorized_public_key: str, tcp_forwarder: HostBridgeTCPForwarder | None = None) -> None:
51
111
  self._authorized_public_key = authorized_public_key.strip()
112
+ self._tcp_forwarder = tcp_forwarder
52
113
 
53
114
  def begin_auth(self, username: str) -> bool: # noqa: ARG002
54
115
  return True
@@ -62,6 +123,21 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
62
123
  def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: # noqa: ARG002
63
124
  return key.export_public_key().decode("utf-8").strip() == self._authorized_public_key
64
125
 
126
+ def connection_requested(
127
+ self,
128
+ dest_host: str,
129
+ dest_port: int,
130
+ orig_host: str, # noqa: ARG002
131
+ orig_port: int, # noqa: ARG002
132
+ ) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
133
+ if self._tcp_forwarder is None:
134
+ return False
135
+ try:
136
+ self._tcp_forwarder.validate_destination(dest_host, dest_port)
137
+ except ValueError as exc:
138
+ raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
139
+ return self._tcp_forwarder.connect(dest_host, dest_port)
140
+
65
141
 
66
142
  class HostBridgeSSHProcess:
67
143
  def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
@@ -305,6 +381,287 @@ class HostBridgeSessionFactory:
305
381
  )
306
382
 
307
383
 
384
+ class _TunnelFrameReader:
385
+ def __init__(self, client: HostBridgeClient, session_id: str, prefix: str) -> None:
386
+ self._client = client
387
+ self._session_id = session_id
388
+ self._prefix = prefix.encode("ascii") + b":"
389
+ self._buffer = bytearray()
390
+ self._pending: deque[tuple[str, bytes]] = deque()
391
+
392
+ async def next(self) -> tuple[str, bytes]:
393
+ while not self._pending:
394
+ result = await asyncio.to_thread(
395
+ self._client.shell_read,
396
+ self._session_id,
397
+ timeout=0.2,
398
+ max_bytes=4096,
399
+ owner="mock-ssh",
400
+ )
401
+ if result.data:
402
+ self._buffer.extend(result.data)
403
+ self._parse_lines()
404
+ if not result.alive and not self._pending:
405
+ raise HostBridgeError("connection_lost", "remote TCP tunnel closed unexpectedly", retryable=True)
406
+ return self._pending.popleft()
407
+
408
+ def _parse_lines(self) -> None:
409
+ while b"\n" in self._buffer:
410
+ raw_line, _, remainder = self._buffer.partition(b"\n")
411
+ self._buffer = bytearray(remainder)
412
+ line = raw_line.rstrip(b"\r")
413
+ marker = line.find(self._prefix)
414
+ if marker < 0:
415
+ continue
416
+ frame = line[marker + len(self._prefix) :]
417
+ kind, separator, encoded = frame.partition(b":")
418
+ try:
419
+ kind_text = kind.decode("ascii")
420
+ except UnicodeDecodeError:
421
+ continue
422
+ if kind_text not in {"READY", "DATA", "EOF", "ERROR", "CLOSED"}:
423
+ continue
424
+ payload = b""
425
+ if separator:
426
+ try:
427
+ payload = base64.b64decode(encoded, validate=True)
428
+ except (binascii.Error, ValueError) as exc:
429
+ raise HostBridgeError("protocol_mismatch", "remote TCP tunnel returned invalid base64") from exc
430
+ self._pending.append((kind_text, payload))
431
+ if len(self._buffer) > TUNNEL_MAX_BUFFER_BYTES:
432
+ marker = self._buffer.rfind(self._prefix)
433
+ self._buffer = self._buffer[marker:] if marker >= 0 else bytearray()
434
+
435
+
436
+ class HostBridgeTCPForwarder:
437
+ def __init__(
438
+ self,
439
+ client: HostBridgeClient,
440
+ session_factory: HostBridgeSessionFactory,
441
+ *,
442
+ connect_timeout: int,
443
+ ) -> None:
444
+ self._client = client
445
+ self._session_factory = session_factory
446
+ self._connect_timeout = connect_timeout
447
+
448
+ @staticmethod
449
+ def validate_destination(dest_host: str, dest_port: int) -> None:
450
+ if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
451
+ raise ValueError("TCP forwarding destination host is invalid")
452
+ if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
453
+ raise ValueError("TCP forwarding destination port must be between 1 and 65535")
454
+
455
+ async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
456
+ self.validate_destination(dest_host, dest_port)
457
+ session_id = await asyncio.to_thread(self._session_factory.open)
458
+ prefix = f"__HB_TUNNEL_{uuid.uuid4().hex}__"
459
+ frames = _TunnelFrameReader(self._client, session_id, prefix)
460
+ try:
461
+ await self._start_remote(session_id, dest_host, dest_port, prefix)
462
+ kind, payload = await asyncio.wait_for(frames.next(), timeout=self._connect_timeout)
463
+ if kind == "ERROR":
464
+ raise HostBridgeError("connection_lost", payload.decode("utf-8", errors="replace"), retryable=True)
465
+ if kind != "READY":
466
+ raise HostBridgeError("protocol_mismatch", f"remote TCP tunnel returned {kind} before READY")
467
+ except asyncio.CancelledError:
468
+ await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
469
+ raise
470
+ except Exception as exc:
471
+ await asyncio.to_thread(self._session_factory.close, session_id, reusable=False)
472
+ raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
473
+ return HostBridgeTCPSession(
474
+ self,
475
+ session_id=session_id,
476
+ prefix=prefix,
477
+ frames=frames,
478
+ dest_host=dest_host,
479
+ dest_port=dest_port,
480
+ )
481
+
482
+ async def _start_remote(self, session_id: str, dest_host: str, dest_port: int, prefix: str) -> None:
483
+ command = (
484
+ f"__hb_prefix={shlex.quote(prefix)}; stty -echo; "
485
+ f"python3 -u -c {shlex.quote(_REMOTE_TUNNEL_SCRIPT)} "
486
+ f"{shlex.quote(dest_host)} {dest_port} \"$__hb_prefix\"; "
487
+ "__hb_status=$?; stty echo; printf '\n%s:CLOSED\n' \"$__hb_prefix\"\n"
488
+ )
489
+ await asyncio.to_thread(
490
+ self._client.shell_write,
491
+ session_id,
492
+ command.encode("utf-8"),
493
+ owner="mock-ssh",
494
+ )
495
+
496
+ async def _send_data(self, session_id: str, prefix: str, payload: bytes) -> None:
497
+ lines = [
498
+ f"{prefix}:DATA:{base64.b64encode(payload[offset : offset + TUNNEL_FRAME_BYTES]).decode('ascii')}\n"
499
+ for offset in range(0, len(payload), TUNNEL_FRAME_BYTES)
500
+ ]
501
+ await self._send_lines(session_id, lines)
502
+
503
+ async def _send_lines(self, session_id: str, lines: list[str]) -> None:
504
+ await asyncio.to_thread(
505
+ self._client.shell_write,
506
+ session_id,
507
+ "".join(lines).encode("ascii"),
508
+ owner="mock-ssh",
509
+ )
510
+
511
+
512
+
513
+ _TUNNEL_INPUT_EOF = object()
514
+
515
+
516
+ class HostBridgeTCPSession(asyncssh.SSHTCPSession[bytes]):
517
+ def __init__(
518
+ self,
519
+ forwarder: HostBridgeTCPForwarder,
520
+ *,
521
+ session_id: str,
522
+ prefix: str,
523
+ frames: _TunnelFrameReader,
524
+ dest_host: str,
525
+ dest_port: int,
526
+ ) -> None:
527
+ self._forwarder = forwarder
528
+ self._session_id = session_id
529
+ self._prefix = prefix
530
+ self._frames = frames
531
+ self._dest_host = dest_host
532
+ self._dest_port = dest_port
533
+ self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
534
+ self._input: asyncio.Queue[bytes | object] = asyncio.Queue()
535
+ self._write_ready = asyncio.Event()
536
+ self._write_ready.set()
537
+ self._runner: asyncio.Task[None] | None = None
538
+ self._orphan_cleanup: asyncio.Task[None] | None = None
539
+ self._released = False
540
+ self._finished = asyncio.Event()
541
+
542
+ def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
543
+ self._channel = chan
544
+
545
+ def session_started(self) -> None:
546
+ self._runner = asyncio.create_task(self._run())
547
+ self._runner.add_done_callback(self._runner_done)
548
+
549
+ def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
550
+ if data:
551
+ self._input.put_nowait(bytes(data))
552
+
553
+ def eof_received(self) -> bool:
554
+ self._input.put_nowait(_TUNNEL_INPUT_EOF)
555
+ return True
556
+
557
+ def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
558
+ self.abort()
559
+
560
+ def pause_writing(self) -> None:
561
+ self._write_ready.clear()
562
+
563
+ def resume_writing(self) -> None:
564
+ self._write_ready.set()
565
+
566
+ def abort(self) -> None:
567
+ if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
568
+ self._runner.cancel()
569
+ elif self._runner is None:
570
+ self._schedule_orphan_cleanup()
571
+
572
+ async def wait_finished(self) -> None:
573
+ await self._finished.wait()
574
+
575
+ def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
576
+ if not self._finished.is_set():
577
+ self._schedule_orphan_cleanup()
578
+
579
+ def _schedule_orphan_cleanup(self) -> None:
580
+ if self._orphan_cleanup is None:
581
+ self._orphan_cleanup = asyncio.create_task(self._finish_orphaned_session())
582
+
583
+ async def _finish_orphaned_session(self) -> None:
584
+ await self._release(reusable=False)
585
+ if self._channel is not None:
586
+ with suppress(Exception):
587
+ self._channel.close()
588
+ self._finished.set()
589
+
590
+ async def _run(self) -> None:
591
+ input_task = asyncio.create_task(self._pump_input())
592
+ output_task = asyncio.create_task(self._pump_output())
593
+ reusable = False
594
+ try:
595
+ done, _ = await asyncio.wait({input_task, output_task}, return_when=asyncio.FIRST_COMPLETED)
596
+ if output_task in done:
597
+ input_task.cancel()
598
+ await asyncio.gather(input_task, return_exceptions=True)
599
+ await output_task
600
+ else:
601
+ await input_task
602
+ await output_task
603
+ reusable = True
604
+ except asyncio.CancelledError:
605
+ raise
606
+ except Exception as exc:
607
+ print(
608
+ f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
609
+ file=sys.stderr,
610
+ flush=True,
611
+ )
612
+ finally:
613
+ for task in (input_task, output_task):
614
+ if not task.done():
615
+ task.cancel()
616
+ await asyncio.gather(input_task, output_task, return_exceptions=True)
617
+ await self._release(reusable=reusable)
618
+ if self._channel is not None:
619
+ with suppress(Exception):
620
+ self._channel.close()
621
+ self._finished.set()
622
+
623
+ async def _release(self, *, reusable: bool) -> None:
624
+ if self._released:
625
+ return
626
+ self._released = True
627
+ await asyncio.to_thread(
628
+ self._forwarder._session_factory.close,
629
+ self._session_id,
630
+ reusable=reusable,
631
+ )
632
+
633
+ async def _pump_input(self) -> None:
634
+ while True:
635
+ item = await self._input.get()
636
+ if item is _TUNNEL_INPUT_EOF:
637
+ await self._forwarder._send_lines(self._session_id, [f"{self._prefix}:EOF\n"])
638
+ return
639
+ await self._forwarder._send_data(self._session_id, self._prefix, cast(bytes, item))
640
+
641
+ async def _pump_output(self) -> None:
642
+ if self._channel is None:
643
+ raise RuntimeError("TCP tunnel channel was not initialized")
644
+ saw_eof = False
645
+ while True:
646
+ kind, payload = await self._frames.next()
647
+ if kind == "DATA":
648
+ await self._write_ready.wait()
649
+ self._channel.write(payload)
650
+ elif kind == "EOF":
651
+ self._channel.write_eof()
652
+ saw_eof = True
653
+ elif kind == "CLOSED":
654
+ if not saw_eof:
655
+ raise HostBridgeError("protocol_mismatch", "remote TCP tunnel closed without EOF")
656
+ return
657
+ elif kind == "ERROR":
658
+ raise HostBridgeError(
659
+ "connection_lost",
660
+ payload.decode("utf-8", errors="replace") or "remote TCP tunnel failed",
661
+ retryable=True,
662
+ )
663
+
664
+
308
665
  class HostBridgeSFTPServer(asyncssh.SFTPServer):
309
666
  def __init__(
310
667
  self,
@@ -781,8 +1138,9 @@ async def serve_mock_ssh(
781
1138
  client = HostBridgeClient()
782
1139
  client.hello()
783
1140
  session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
1141
+ tcp_forwarder = HostBridgeTCPForwarder(client, session_factory, connect_timeout=connect_timeout)
784
1142
  server, selected_port = await create_server_on_available_port(
785
- server_factory=lambda: HostBridgeSSHServer(public_key),
1143
+ server_factory=lambda: HostBridgeSSHServer(public_key, tcp_forwarder),
786
1144
  listen_host=listen_host,
787
1145
  port=port,
788
1146
  server_host_keys=[str(host_key_path)],
@@ -804,7 +1162,7 @@ async def serve_mock_ssh(
804
1162
  if installed_config_path is not None:
805
1163
  print(f"ssh config: {installed_config_path}", flush=True)
806
1164
  print(f"identity file: {client_key_path}", flush=True)
807
- print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
1165
+ print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
808
1166
  try:
809
1167
  await server.wait_closed()
810
1168
  return 0