@neoline/hostbridge 2.0.1 → 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
package/pyproject.toml
CHANGED
|
@@ -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.
|
|
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:
|
|
@@ -121,9 +197,9 @@ class HostBridgeSSHProcess:
|
|
|
121
197
|
process.exit(status)
|
|
122
198
|
|
|
123
199
|
async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
|
|
124
|
-
if process.stdin.at_eof():
|
|
125
|
-
return b""
|
|
126
200
|
waits_for_eof = _command_reads_stdin(command)
|
|
201
|
+
if process.stdin.at_eof():
|
|
202
|
+
return b"" if waits_for_eof else None
|
|
127
203
|
try:
|
|
128
204
|
first_chunk = await asyncio.wait_for(
|
|
129
205
|
process.stdin.read(4096),
|
|
@@ -134,7 +210,7 @@ class HostBridgeSSHProcess:
|
|
|
134
210
|
raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
|
|
135
211
|
return None
|
|
136
212
|
if not first_chunk:
|
|
137
|
-
return b""
|
|
213
|
+
return b"" if waits_for_eof else None
|
|
138
214
|
|
|
139
215
|
first_chunk_bytes = _to_bytes(first_chunk)
|
|
140
216
|
total = len(first_chunk_bytes)
|
|
@@ -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
|
|
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
|
|
@@ -163,8 +163,7 @@ class PtyTransport:
|
|
|
163
163
|
raise TransportError("PTY transport is not alive")
|
|
164
164
|
if stdin is not None:
|
|
165
165
|
stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
|
|
166
|
-
|
|
167
|
-
return self._exec_locked(command, timeout=timeout, output_limit=output_limit)
|
|
166
|
+
return self._exec_locked(command, timeout=timeout, output_limit=output_limit, stdin_path=stdin_path)
|
|
168
167
|
finally:
|
|
169
168
|
if stdin_path is not None and self.child.isalive():
|
|
170
169
|
with suppress(Exception):
|
|
@@ -205,15 +204,23 @@ class PtyTransport:
|
|
|
205
204
|
raise TransportError("failed to stage PTY stdin")
|
|
206
205
|
return path
|
|
207
206
|
|
|
208
|
-
def _exec_locked(
|
|
207
|
+
def _exec_locked(
|
|
208
|
+
self,
|
|
209
|
+
command: str,
|
|
210
|
+
*,
|
|
211
|
+
timeout: float,
|
|
212
|
+
output_limit: int,
|
|
213
|
+
stdin_path: str | None = None,
|
|
214
|
+
) -> ExecResult:
|
|
209
215
|
token = self._token_factory()
|
|
210
216
|
start_marker = f"__{self._marker_prefix}_START_{token}__"
|
|
211
217
|
exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
|
|
212
218
|
encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
|
|
219
|
+
stdin_redirect = f" < {shlex.quote(stdin_path)}" if stdin_path is not None else ""
|
|
213
220
|
remote_line = (
|
|
214
221
|
f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
|
|
215
222
|
f"printf '\n{start_marker}\n'; "
|
|
216
|
-
f"{self.remote_shell} -lc \"$__scm_cmd\"; "
|
|
223
|
+
f"{self.remote_shell} -lc \"$__scm_cmd\"{stdin_redirect}; "
|
|
217
224
|
"__scm_status=$?; "
|
|
218
225
|
f"printf '\n__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\""
|
|
219
226
|
)
|