@neoline/hostbridge 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,28 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import json
5
- import posixpath
6
- import re
7
- import shlex
8
- import sys
9
- import tempfile
10
- import threading
11
- import time
12
- from contextlib import suppress
13
- from dataclasses import dataclass, field
14
- from functools import partial
4
+ from collections.abc import Awaitable
15
5
  from pathlib import Path
16
6
  from typing import Any, cast
17
7
 
18
8
  import asyncssh
19
9
 
20
10
  from .client import HostBridgeClient, HostBridgeError
11
+ from .mock_ssh_exec import HostBridgeSSHProcess
12
+ from .mock_ssh_session import HostBridgeSessionFactory
13
+ from .mock_ssh_sftp import HostBridgeSFTPServer
14
+ from .mock_ssh_tunnel import TunnelConnector
15
+ from .secure_files import create_private_file, ensure_private_directory, replace_private_file, validate_private_file
21
16
 
22
17
  DEFAULT_PORT_RANGE = range(2222, 2300)
23
18
  DEFAULT_COMMAND_TIMEOUT = 60
24
19
  DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
25
- EXEC_STDIN_PROBE_TIMEOUT = 0.05
26
- STDIN_READING_COMMANDS = [
27
- re.compile(r"\bcat\s*(?:>|>>)"),
28
- re.compile(r"\btee(?:\s|$)"),
29
- re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
30
- re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
31
- re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
32
- re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
33
- re.compile(r"(?:^|\s)dd\s+.*\bof="),
34
- re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
35
- ]
36
- MAX_IDLE_BACKEND_SESSIONS = 4
37
-
38
-
39
- def _to_bytes(chunk: str | bytes) -> bytes:
40
- if isinstance(chunk, bytes):
41
- return chunk
42
- return chunk.encode("utf-8")
43
-
44
-
45
- def _command_reads_stdin(command: str | None) -> bool:
46
- return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
47
20
 
48
21
 
49
22
  class HostBridgeSSHServer(asyncssh.SSHServer):
50
- def __init__(self, authorized_public_key: str) -> None:
23
+ def __init__(self, authorized_public_key: str, tunnel_connector: TunnelConnector | None = None) -> None:
51
24
  self._authorized_public_key = authorized_public_key.strip()
25
+ self._tunnel_connector = tunnel_connector
52
26
 
53
27
  def begin_auth(self, username: str) -> bool: # noqa: ARG002
54
28
  return True
@@ -62,562 +36,36 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
62
36
  def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: # noqa: ARG002
63
37
  return key.export_public_key().decode("utf-8").strip() == self._authorized_public_key
64
38
 
65
-
66
- class HostBridgeSSHProcess:
67
- def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
68
- self._client = client
69
- self._session_factory = session_factory
70
- self._timeout = timeout
71
- self._output_limit = output_limit
72
-
73
- async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
74
- session_id = self._session_factory.open()
75
- reusable = False
76
- try:
77
- reusable = await self._run(process, session_id)
78
- except asyncio.CancelledError:
79
- self._set_exit_status(process, 255)
80
- return
81
- finally:
82
- self._session_factory.close(session_id, reusable=reusable)
83
-
84
- async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
85
- command = process.command
86
- if not command:
87
- await self._run_shell(process, session_id)
88
- return False
89
-
90
- loop = asyncio.get_running_loop()
91
- try:
92
- stdin_data = await self._read_exec_stdin(process, command)
93
- result = await loop.run_in_executor(
94
- None,
95
- lambda: self._client.exec(
96
- session_id,
97
- command,
98
- stdin=stdin_data,
99
- timeout=self._timeout,
100
- output_limit=self._output_limit,
101
- owner="mock-ssh",
102
- ),
103
- )
104
- if result.stdout:
105
- process.stdout.write(result.stdout)
106
- if result.stderr:
107
- process.stderr.write(result.stderr)
108
- self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
109
- return not result.timed_out
110
- except Exception as exc:
111
- print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
112
- with suppress(Exception):
113
- process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
114
- status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
115
- self._set_exit_status(process, status)
116
- return False
117
-
118
- @staticmethod
119
- def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
120
- with suppress(Exception):
121
- process.exit(status)
122
-
123
- async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
124
- waits_for_eof = _command_reads_stdin(command)
125
- if process.stdin.at_eof():
126
- return b"" if waits_for_eof else None
127
- try:
128
- first_chunk = await asyncio.wait_for(
129
- process.stdin.read(4096),
130
- timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
131
- )
132
- except TimeoutError:
133
- if waits_for_eof:
134
- raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
135
- return None
136
- if not first_chunk:
137
- return b"" if waits_for_eof else None
138
-
139
- first_chunk_bytes = _to_bytes(first_chunk)
140
- total = len(first_chunk_bytes)
141
- if total > self._output_limit:
142
- raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
143
- chunks = [first_chunk_bytes]
144
- while not process.stdin.at_eof():
145
- chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
146
- if not chunk:
147
- break
148
- chunk_bytes = _to_bytes(chunk)
149
- total += len(chunk_bytes)
150
- if total > self._output_limit:
151
- raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
152
- chunks.append(chunk_bytes)
153
- return b"".join(chunks)
154
-
155
- async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
156
- loop = asyncio.get_running_loop()
157
- stop = asyncio.Event()
158
- input_done = asyncio.Event()
159
-
160
- async def pump_input() -> None:
161
- try:
162
- while not process.stdin.at_eof():
163
- data = await process.stdin.read(4096)
164
- if not data:
165
- break
166
- await loop.run_in_executor(
167
- None,
168
- partial(
169
- self._client.shell_write,
170
- session_id,
171
- _to_bytes(data),
172
- owner="mock-ssh",
173
- ),
174
- )
175
- finally:
176
- input_done.set()
177
-
178
- async def pump_output() -> None:
179
- try:
180
- idle_after_input = 0
181
- while not stop.is_set():
182
- result = await loop.run_in_executor(
183
- None,
184
- lambda: self._client.shell_read(
185
- session_id,
186
- timeout=0.2,
187
- max_bytes=4096,
188
- owner="mock-ssh",
189
- ),
190
- )
191
- if result.data:
192
- idle_after_input = 0
193
- process.stdout.write(result.data)
194
- elif input_done.is_set():
195
- idle_after_input += 1
196
- if idle_after_input >= 5:
197
- stop.set()
198
- break
199
- if not result.alive:
200
- stop.set()
201
- break
202
- except Exception as exc:
203
- print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
204
- with suppress(Exception):
205
- process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
206
- stop.set()
207
-
208
- tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
209
- await stop.wait()
210
- for task in tasks:
211
- task.cancel()
212
- await asyncio.gather(*tasks, return_exceptions=True)
213
- self._set_exit_status(process, 0)
214
-
215
-
216
- @dataclass
217
- class HostBridgeSFTPHandle:
218
- path: str
219
- readable: bool = False
220
- writable: bool = False
221
- data: bytes = b""
222
- writes: dict[int, bytes] = field(default_factory=dict)
223
- mode: int = 0o644
224
-
225
-
226
- @dataclass(frozen=True, slots=True)
227
- class _TextCommandResult:
228
- output: str
229
- exit_code: int | None
230
- timed_out: bool
231
-
232
-
233
- class HostBridgeSessionFactory:
234
- def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
235
- self._client = client
236
- self._host_id = host_id
237
- self._connect_timeout = connect_timeout
238
- self._lock = threading.Lock()
239
- self._idle: list[str] = []
240
- self._leased: set[str] = set()
241
- self._shutdown = False
242
-
243
- def open(self) -> str:
244
- with self._lock:
245
- if self._shutdown:
246
- raise RuntimeError("mock-ssh session factory is shut down")
247
- if self._idle:
248
- session_id = self._idle.pop()
249
- self._leased.add(session_id)
250
- return session_id
251
- session = self._client.open_session(
252
- self._host_id,
253
- owner="mock-ssh",
254
- timeout=self._connect_timeout + 5,
255
- )
256
- session_id = session.session_id
257
- with self._lock:
258
- if not self._shutdown:
259
- self._leased.add(session_id)
260
- return session_id
261
- self._close_backend(session_id)
262
- raise RuntimeError("mock-ssh session factory is shut down")
263
-
264
- def close(self, session_id: str, *, reusable: bool = True) -> None:
265
- should_close = False
266
- with self._lock:
267
- if session_id not in self._leased:
268
- return
269
- self._leased.remove(session_id)
270
- if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
271
- self._idle.append(session_id)
272
- else:
273
- should_close = True
274
- if not should_close:
275
- return
276
- self._close_backend(session_id)
277
-
278
- def shutdown(self) -> None:
279
- with self._lock:
280
- self._shutdown = True
281
- session_ids = [*self._idle, *self._leased]
282
- self._idle.clear()
283
- self._leased.clear()
284
- for session_id in session_ids:
285
- self._close_backend(session_id)
286
-
287
- def _close_backend(self, session_id: str) -> None:
288
- last_error: Exception | None = None
289
- for attempt in range(2):
290
- try:
291
- self._client.close_session(session_id, owner="mock-ssh", force=True)
292
- return
293
- except HostBridgeError as exc:
294
- if exc.code == "not_found":
295
- return
296
- last_error = exc
297
- except Exception as exc:
298
- last_error = exc
299
- if attempt == 0:
300
- time.sleep(0.05)
301
- print(
302
- f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
303
- file=sys.stderr,
304
- flush=True,
305
- )
306
-
307
-
308
- class HostBridgeSFTPServer(asyncssh.SFTPServer):
309
- def __init__(
39
+ def connection_requested(
310
40
  self,
311
- chan: asyncssh.SSHServerChannel,
312
- client: HostBridgeClient,
313
- session_factory: HostBridgeSessionFactory,
314
- timeout: int,
315
- max_bytes: int,
316
- ) -> None:
317
- super().__init__(chan)
318
- self._client = client
319
- self._session_factory = session_factory
320
- self._session_id = session_factory.open()
321
- self._timeout = timeout
322
- self._max_bytes = max_bytes
323
-
324
- def exit(self) -> None:
325
- self._session_factory.close(self._session_id)
326
-
327
- def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
328
- remote_path = self._path(path)
329
- readable = bool(pflags & asyncssh.FXF_READ)
330
- writable = bool(pflags & asyncssh.FXF_WRITE)
331
- mode = attrs.permissions if attrs.permissions is not None else 0o644
332
- data = b""
333
- if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
334
- try:
335
- data = self._download(remote_path)
336
- except asyncssh.SFTPNoSuchFile:
337
- if not writable:
338
- raise
339
- data = b""
340
- return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
341
-
342
- def read(self, file_obj: object, offset: int, size: int) -> bytes:
343
- handle = self._handle(file_obj)
344
- if not handle.readable:
345
- raise asyncssh.SFTPPermissionDenied("file is not open for reading")
346
- return handle.data[offset : offset + size]
347
-
348
- def write(self, file_obj: object, offset: int, data: bytes) -> int:
349
- handle = self._handle(file_obj)
350
- if not handle.writable:
351
- raise asyncssh.SFTPPermissionDenied("file is not open for writing")
352
- handle.writes[int(offset)] = bytes(data)
353
- return len(data)
354
-
355
- def close(self, file_obj: object) -> None:
356
- handle = self._handle(file_obj)
357
- if handle.writable:
358
- self._upload(handle)
359
-
360
- def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
361
- return self._stat(self._path(path), follow=True)
362
-
363
- def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
364
- return self._stat(self._path(path), follow=False)
365
-
366
- def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
367
- handle = self._handle(file_obj)
368
- if handle.writable and handle.writes:
369
- size = len(handle.data)
370
- for offset, chunk in handle.writes.items():
371
- size = max(size, offset + len(chunk))
372
- return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
373
- return self._stat(handle.path, follow=True)
374
-
375
- def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
376
- handle = self._handle(file_obj)
377
- if attrs.permissions is not None:
378
- handle.mode = attrs.permissions
379
- if attrs.atime is not None or attrs.mtime is not None:
380
- # Applied after upload in close(); the handle may not exist remotely yet.
381
- return
382
-
383
- async def scandir(self, path: bytes):
384
- for item in self._listdir(self._path(path)):
385
- yield item
386
-
387
- def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
388
- mode = attrs.permissions if attrs.permissions is not None else 0o755
389
- self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
390
-
391
- def rmdir(self, path: bytes) -> None:
392
- self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
393
-
394
- def remove(self, path: bytes) -> None:
395
- self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
396
-
397
- def rename(self, oldpath: bytes, newpath: bytes) -> None:
398
- self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
399
-
400
- def realpath(self, path: bytes) -> bytes:
401
- script = (
402
- "import os,sys; "
403
- "path=sys.argv[1]; "
404
- "parent=os.path.dirname(path) or '/'; "
405
- "base=os.path.basename(path); "
406
- "print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
407
- )
408
- command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
409
- result = self._checked_run(command)
410
- return (result.output.strip() or self._path(path)).encode("utf-8")
411
-
412
- def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
413
- remote_path = self._path(path)
414
- if attrs.permissions is not None:
415
- self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
416
- if attrs.atime is not None or attrs.mtime is not None:
417
- script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
418
- current = self._stat(remote_path, follow=True)
419
- atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
420
- mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
421
- self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
422
-
423
- def _download(self, remote_path: str) -> bytes:
424
- temporary_path: Path | None = None
425
- try:
426
- with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
427
- temporary_path = Path(temporary.name)
428
- result = self._client.download_file(
429
- self._session_id,
430
- remote_path,
431
- temporary_path,
432
- timeout=self._timeout,
433
- owner="mock-ssh",
434
- )
435
- if result.bytes_transferred > self._max_bytes:
436
- raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
437
- return temporary_path.read_bytes()
438
- except HostBridgeError as exc:
439
- raise self._sftp_error(exc) from exc
440
- finally:
441
- if temporary_path is not None:
442
- temporary_path.unlink(missing_ok=True)
443
-
444
- def _upload(self, handle: HostBridgeSFTPHandle) -> None:
445
- size = len(handle.data)
446
- for offset, chunk in handle.writes.items():
447
- size = max(size, offset + len(chunk))
448
- data = bytearray(handle.data)
449
- if len(data) < size:
450
- data.extend(b"\x00" * (size - len(data)))
451
- for offset, chunk in sorted(handle.writes.items()):
452
- data[offset : offset + len(chunk)] = chunk
453
- if len(data) > self._max_bytes:
454
- raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
455
- temporary_path: Path | None = None
456
- try:
457
- with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
458
- temporary.write(data)
459
- temporary_path = Path(temporary.name)
460
- self._client.upload_file(
461
- self._session_id,
462
- temporary_path,
463
- handle.path,
464
- mode=handle.mode & 0o7777,
465
- timeout=self._timeout,
466
- owner="mock-ssh",
467
- )
468
- except HostBridgeError as exc:
469
- print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
470
- raise self._sftp_error(exc) from exc
471
- finally:
472
- if temporary_path is not None:
473
- temporary_path.unlink(missing_ok=True)
474
-
475
- def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
476
- script = r"""
477
- import json, os, stat, sys
478
- path = sys.argv[1]
479
- st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
480
- print(json.dumps({
481
- "size": st.st_size,
482
- "permissions": st.st_mode,
483
- "uid": st.st_uid,
484
- "gid": st.st_gid,
485
- "atime": int(st.st_atime),
486
- "mtime": int(st.st_mtime),
487
- "type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
488
- }))
489
- """
490
- result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
491
- return self._attrs(json.loads(result.output))
492
-
493
- def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
494
- script = r"""
495
- import json, os, stat, sys, time
496
- path = sys.argv[1]
497
- items = []
498
- for name in os.listdir(path):
499
- full = os.path.join(path, name)
500
- st = os.lstat(full)
501
- item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
502
- items.append({
503
- "filename": name,
504
- "longname": name,
505
- "attrs": {
506
- "size": st.st_size,
507
- "permissions": st.st_mode,
508
- "uid": st.st_uid,
509
- "gid": st.st_gid,
510
- "atime": int(st.st_atime),
511
- "mtime": int(st.st_mtime),
512
- "type": item_type,
513
- },
514
- })
515
- print(json.dumps(items))
516
- """
517
- result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
518
- return [
519
- asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
520
- for item in json.loads(result.output or "[]")
521
- ]
522
-
523
- def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
524
- permissions = self._int_value(data.get("permissions"), 0)
525
- raw_type = data.get("type")
526
- file_type = {
527
- "file": asyncssh.FILEXFER_TYPE_REGULAR,
528
- "dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
529
- "link": asyncssh.FILEXFER_TYPE_SYMLINK,
530
- }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
531
- return asyncssh.SFTPAttrs(
532
- type=file_type,
533
- size=self._int_value(data.get("size"), 0),
534
- uid=self._int_value(data.get("uid"), 0),
535
- gid=self._int_value(data.get("gid"), 0),
536
- permissions=permissions,
537
- atime=self._int_value(data.get("atime"), int(time.time())),
538
- mtime=self._int_value(data.get("mtime"), int(time.time())),
539
- )
540
-
541
- @staticmethod
542
- def _int_value(value: object, default: int) -> int:
543
- if value is None:
544
- return default
545
- if isinstance(value, bool) or not isinstance(value, int | float | str):
546
- raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
41
+ dest_host: str,
42
+ dest_port: int,
43
+ orig_host: str, # noqa: ARG002
44
+ orig_port: int, # noqa: ARG002
45
+ ) -> Awaitable[asyncssh.SSHTCPSession[bytes]] | bool:
46
+ if self._tunnel_connector is None:
47
+ return False
547
48
  try:
548
- return int(value)
49
+ self._tunnel_connector.validate_destination(dest_host, dest_port)
549
50
  except ValueError as exc:
550
- raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
551
-
552
- def _checked_run(self, command: str):
553
- try:
554
- result = self._client.exec(
555
- self._session_id,
556
- command,
557
- timeout=self._timeout,
558
- owner="mock-ssh",
559
- )
560
- except HostBridgeError as exc:
561
- raise self._sftp_error(exc) from exc
562
- output = result.stdout.decode("utf-8", errors="replace")
563
- stderr = result.stderr.decode("utf-8", errors="replace")
564
- if result.timed_out:
565
- print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
566
- raise asyncssh.SFTPFailure("remote command timed out")
567
- if result.exit_code not in (0, None):
568
- error_output = "\n".join(part for part in (output, stderr) if part).strip()
569
- error = self._sftp_error(
570
- HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
571
- )
572
- if isinstance(error, asyncssh.SFTPNoSuchFile):
573
- raise error
574
- print(
575
- f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
576
- file=sys.stderr,
577
- flush=True,
578
- )
579
- raise error
580
- return _TextCommandResult(output, result.exit_code, result.timed_out)
581
-
582
- def _path(self, path: bytes) -> str:
583
- text = path.decode("utf-8", "surrogateescape")
584
- if not text:
585
- return "."
586
- return posixpath.normpath(text)
587
-
588
- def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
589
- if not isinstance(file_obj, HostBridgeSFTPHandle):
590
- raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
591
- return file_obj
592
-
593
- def _sftp_error(self, exc: HostBridgeError) -> Exception:
594
- text = str(exc)
595
- lowered = text.lower()
596
- if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
597
- return asyncssh.SFTPNoSuchFile(text)
598
- if "permission denied" in lowered:
599
- return asyncssh.SFTPPermissionDenied(text)
600
- return asyncssh.SFTPFailure(text)
51
+ raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, str(exc)) from exc
52
+ return self._tunnel_connector.connect(dest_host, dest_port)
601
53
 
602
54
 
603
55
  def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
604
- key_dir.mkdir(parents=True, exist_ok=True)
605
- with _suppress_chmod_error(key_dir):
606
- key_dir.chmod(0o700)
56
+ ensure_private_directory(key_dir)
607
57
  host_key_path = key_dir / "ssh_host_ed25519_key"
608
58
  client_key_path = key_dir / "client_ed25519_key"
609
- if not host_key_path.exists():
610
- host_key = asyncssh.generate_private_key("ssh-ed25519")
611
- host_key_path.write_bytes(host_key.export_private_key())
612
- if not client_key_path.exists():
613
- client_key = asyncssh.generate_private_key("ssh-ed25519")
614
- client_key_path.write_bytes(client_key.export_private_key())
615
- (key_dir / "client_ed25519_key.pub").write_bytes(client_key.export_public_key())
616
- with _suppress_chmod_error(host_key_path):
617
- host_key_path.chmod(0o600)
618
- with _suppress_chmod_error(client_key_path):
619
- client_key_path.chmod(0o600)
620
- public_key = (key_dir / "client_ed25519_key.pub").read_text(encoding="utf-8").strip()
59
+ for path in (host_key_path, client_key_path):
60
+ if path.exists():
61
+ validate_private_file(path)
62
+ continue
63
+ private_key = asyncssh.generate_private_key("ssh-ed25519")
64
+ create_private_file(path, private_key.export_private_key())
65
+ client_key = asyncssh.import_private_key(client_key_path.read_bytes())
66
+ public_key_path = key_dir / "client_ed25519_key.pub"
67
+ replace_private_file(public_key_path, client_key.export_public_key())
68
+ public_key = public_key_path.read_text(encoding="utf-8").strip()
621
69
  return host_key_path, client_key_path, public_key
622
70
 
623
71
 
@@ -662,7 +110,9 @@ async def create_server_on_available_port(
662
110
  continue
663
111
  if port is not None:
664
112
  raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
665
- raise RuntimeError(f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}") from last_error
113
+ raise RuntimeError(
114
+ f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}"
115
+ ) from last_error
666
116
 
667
117
 
668
118
  def install_ssh_config_block(
@@ -728,13 +178,15 @@ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
728
178
  stop += len(end)
729
179
  while stop < len(existing) and existing[stop] in "\r\n":
730
180
  stop += 1
731
- return existing[:start].rstrip() + ("\n" if existing[:start].strip() and existing[stop:].strip() else "") + existing[stop:].lstrip()
181
+ return (
182
+ existing[:start].rstrip()
183
+ + ("\n" if existing[:start].strip() and existing[stop:].strip() else "")
184
+ + existing[stop:].lstrip()
185
+ )
732
186
 
733
187
 
734
188
  def _write_text_atomic(path: Path, text: str) -> None:
735
- tmp_path = path.with_name(f".{path.name}.hostbridge.tmp")
736
- tmp_path.write_text(text, encoding="utf-8")
737
- tmp_path.replace(path)
189
+ replace_private_file(path, text.encode("utf-8"))
738
190
 
739
191
 
740
192
  class _suppress_chmod_error:
@@ -781,35 +233,40 @@ async def serve_mock_ssh(
781
233
  client = HostBridgeClient()
782
234
  client.hello()
783
235
  session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
236
+ tunnel_connector = TunnelConnector(client, session_factory, connect_timeout=connect_timeout)
784
237
  server, selected_port = await create_server_on_available_port(
785
- server_factory=lambda: HostBridgeSSHServer(public_key),
238
+ server_factory=lambda: HostBridgeSSHServer(public_key, tunnel_connector),
786
239
  listen_host=listen_host,
787
240
  port=port,
788
241
  server_host_keys=[str(host_key_path)],
789
242
  process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
790
243
  sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
791
244
  )
792
- installed_config_path: Path | None = None
793
- if install_ssh_config:
794
- installed_config_path = install_ssh_config_block(
795
- host_alias=ssh_host_alias,
796
- listen_host=listen_host,
797
- port=selected_port,
798
- client_key_path=client_key_path,
799
- config_path=ssh_config_path,
800
- )
801
- print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
802
- print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
803
- print(f"ssh host alias: {ssh_host_alias}", flush=True)
804
- if installed_config_path is not None:
805
- print(f"ssh config: {installed_config_path}", flush=True)
806
- 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)
808
245
  try:
246
+ installed_config_path: Path | None = None
247
+ if install_ssh_config:
248
+ installed_config_path = install_ssh_config_block(
249
+ host_alias=ssh_host_alias,
250
+ listen_host=listen_host,
251
+ port=selected_port,
252
+ client_key_path=client_key_path,
253
+ config_path=ssh_config_path,
254
+ )
255
+ print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
256
+ print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
257
+ print(f"ssh host alias: {ssh_host_alias}", flush=True)
258
+ if installed_config_path is not None:
259
+ print(f"ssh config: {installed_config_path}", flush=True)
260
+ print(f"identity file: {client_key_path}", flush=True)
261
+ print("supports SSH exec, shell, SFTP, SCP, and TCP forwarding; press Ctrl-C to stop", flush=True)
809
262
  await server.wait_closed()
810
263
  return 0
811
264
  finally:
812
- session_factory.shutdown()
265
+ try:
266
+ server.close()
267
+ await server.wait_closed()
268
+ finally:
269
+ session_factory.shutdown()
813
270
 
814
271
 
815
272
  def run_mock_ssh(**kwargs: object) -> int: