tryorchard-sdk 0.0.1__tar.gz

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 (35) hide show
  1. tryorchard_sdk-0.0.1/LICENSE +21 -0
  2. tryorchard_sdk-0.0.1/PKG-INFO +137 -0
  3. tryorchard_sdk-0.0.1/README.md +101 -0
  4. tryorchard_sdk-0.0.1/orchard_sdk/__init__.py +37 -0
  5. tryorchard_sdk-0.0.1/orchard_sdk/_bridge.py +121 -0
  6. tryorchard_sdk-0.0.1/orchard_sdk/_channels.py +191 -0
  7. tryorchard_sdk-0.0.1/orchard_sdk/_http.py +100 -0
  8. tryorchard_sdk-0.0.1/orchard_sdk/_version.py +1 -0
  9. tryorchard_sdk-0.0.1/orchard_sdk/_ws.py +92 -0
  10. tryorchard_sdk-0.0.1/orchard_sdk/aio/__init__.py +6 -0
  11. tryorchard_sdk-0.0.1/orchard_sdk/aio/channels.py +57 -0
  12. tryorchard_sdk-0.0.1/orchard_sdk/client.py +82 -0
  13. tryorchard_sdk-0.0.1/orchard_sdk/config.py +69 -0
  14. tryorchard_sdk-0.0.1/orchard_sdk/errors.py +50 -0
  15. tryorchard_sdk-0.0.1/orchard_sdk/models.py +139 -0
  16. tryorchard_sdk-0.0.1/orchard_sdk/py.typed +0 -0
  17. tryorchard_sdk-0.0.1/orchard_sdk/resources/__init__.py +4 -0
  18. tryorchard_sdk-0.0.1/orchard_sdk/resources/keys.py +46 -0
  19. tryorchard_sdk-0.0.1/orchard_sdk/resources/misc.py +111 -0
  20. tryorchard_sdk-0.0.1/orchard_sdk/resources/remote.py +88 -0
  21. tryorchard_sdk-0.0.1/orchard_sdk/resources/sandboxes.py +432 -0
  22. tryorchard_sdk-0.0.1/orchard_sdk/resources/team.py +108 -0
  23. tryorchard_sdk-0.0.1/orchard_sdk/selector.py +53 -0
  24. tryorchard_sdk-0.0.1/pyproject.toml +52 -0
  25. tryorchard_sdk-0.0.1/setup.cfg +4 -0
  26. tryorchard_sdk-0.0.1/tests/test_config.py +31 -0
  27. tryorchard_sdk-0.0.1/tests/test_http_selector.py +54 -0
  28. tryorchard_sdk-0.0.1/tests/test_resources.py +128 -0
  29. tryorchard_sdk-0.0.1/tests/test_smoke_live.py +29 -0
  30. tryorchard_sdk-0.0.1/tests/test_ws.py +54 -0
  31. tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/PKG-INFO +137 -0
  32. tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/SOURCES.txt +33 -0
  33. tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/dependency_links.txt +1 -0
  34. tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/requires.txt +12 -0
  35. tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AirOverflow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: tryorchard-sdk
3
+ Version: 0.0.1
4
+ Summary: Python client for Orchard: drive iOS and Android sandboxes over REST and live WebSocket channels.
5
+ Author: AirOverflow
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/tryorchard/sdk
8
+ Project-URL: Documentation, https://github.com/tryorchard/sdk/blob/main/python/docs/reference.md
9
+ Project-URL: Source, https://github.com/tryorchard/sdk/tree/main/python
10
+ Project-URL: Issues, https://github.com/tryorchard/sdk/issues
11
+ Keywords: orchard,android,ios,sandbox,emulator,automation,frida
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: httpx<0.29,>=0.28
27
+ Requires-Dist: websockets<15,>=14
28
+ Requires-Dist: typing_extensions>=4.7; python_version < "3.11"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
32
+ Requires-Dist: mypy>=1.11; extra == "dev"
33
+ Requires-Dist: build>=1.2; extra == "dev"
34
+ Requires-Dist: twine>=6; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # tryorchard-sdk
38
+
39
+ Python client for [Orchard](https://docs.orchard.airoverflow.com): create and drive iOS and Android sandboxes, manage snapshots, templates, teams and scoped API keys, and stream live channels (logs, shell, screen, adb, VNC, mitm, frida).
40
+
41
+ ```bash
42
+ pip install tryorchard-sdk
43
+ ```
44
+
45
+ Requires Python 3.10+. The import name is `orchard_sdk`.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from orchard_sdk import Orchard
51
+
52
+ orchard = Orchard.from_env() # ORCHARD_API_URL + ORCHARD_API_KEY
53
+ # or: Orchard("https://orchard.example.com", "orc_live_xxx")
54
+
55
+ for s in orchard.sandboxes.list()["instances"]:
56
+ print(s["name"], s["state"])
57
+
58
+ sb = orchard.sandbox("my-pixel") # uuid, uuid prefix, name or tag
59
+ sb.start()
60
+ sb.apps.install("./build/app.apk")
61
+ sb.launch("com.example.app")
62
+ sb.tap_text("Sign in")
63
+
64
+ with open("screen.png", "wb") as f:
65
+ f.write(sb.screenshot())
66
+
67
+ with sb.logs() as stream:
68
+ for line in stream:
69
+ print(line)
70
+ ```
71
+
72
+ ## Configuration
73
+
74
+ | Variable | Purpose |
75
+ | --- | --- |
76
+ | `ORCHARD_API_URL` | Control-plane URL, e.g. `https://orchard.example.com` |
77
+ | `ORCHARD_API_KEY` | API key (`orc_…`) or JWT access token |
78
+ | `ORCHARD_ORG` | Optional org slug (ignored for API keys) |
79
+ | `ORCHARD_WS` | Optional workspace slug |
80
+
81
+ Explicit arguments to `Orchard(...)` win over the environment. The client holds a connection pool: use it as a context manager or call `close()`.
82
+
83
+ ## Live channels
84
+
85
+ Channels are context-managed iterators that work in ordinary synchronous code:
86
+
87
+ ```python
88
+ with sb.shell() as sh:
89
+ sh.send("getprop ro.build.version.release\n")
90
+ for out in sh:
91
+ print(out, end="")
92
+ ```
93
+
94
+ For asyncio code use `orchard_sdk.aio.AsyncChannels`:
95
+
96
+ ```python
97
+ import asyncio
98
+ from orchard_sdk import from_env
99
+ from orchard_sdk.aio import AsyncChannels
100
+
101
+ async def main():
102
+ async for line in AsyncChannels(from_env(), sb.uuid).logs():
103
+ print(line)
104
+
105
+ asyncio.run(main())
106
+ ```
107
+
108
+ Expose a device channel on a local port for ordinary tools:
109
+
110
+ ```python
111
+ with sb.adb_bridge() as bridge:
112
+ print(f"adb connect 127.0.0.1:{bridge.port}")
113
+ ```
114
+
115
+ ## Errors
116
+
117
+ All errors derive from `OrchardError`: `ApiError` (HTTP status + server detail), `SelectorError` (zero or ambiguous sandbox match), `ChannelClosedError` (WebSocket closed with 4401/4403/4404/4502).
118
+
119
+ ```python
120
+ from orchard_sdk import ApiError, SelectorError
121
+
122
+ try:
123
+ orchard.sandbox("pixel").start()
124
+ except SelectorError as e:
125
+ print(e)
126
+ except ApiError as e:
127
+ print(e.status, e.detail)
128
+ ```
129
+
130
+ ## Documentation
131
+
132
+ - [API reference](https://github.com/tryorchard/sdk/blob/main/python/docs/reference.md): every method, with examples
133
+ - [Examples](https://github.com/tryorchard/sdk/tree/main/python/examples): install and launch, UI test with snapshot rollback, frida tracing, traffic capture, adb bridge, scoped keys, log streaming
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,101 @@
1
+ # tryorchard-sdk
2
+
3
+ Python client for [Orchard](https://docs.orchard.airoverflow.com): create and drive iOS and Android sandboxes, manage snapshots, templates, teams and scoped API keys, and stream live channels (logs, shell, screen, adb, VNC, mitm, frida).
4
+
5
+ ```bash
6
+ pip install tryorchard-sdk
7
+ ```
8
+
9
+ Requires Python 3.10+. The import name is `orchard_sdk`.
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from orchard_sdk import Orchard
15
+
16
+ orchard = Orchard.from_env() # ORCHARD_API_URL + ORCHARD_API_KEY
17
+ # or: Orchard("https://orchard.example.com", "orc_live_xxx")
18
+
19
+ for s in orchard.sandboxes.list()["instances"]:
20
+ print(s["name"], s["state"])
21
+
22
+ sb = orchard.sandbox("my-pixel") # uuid, uuid prefix, name or tag
23
+ sb.start()
24
+ sb.apps.install("./build/app.apk")
25
+ sb.launch("com.example.app")
26
+ sb.tap_text("Sign in")
27
+
28
+ with open("screen.png", "wb") as f:
29
+ f.write(sb.screenshot())
30
+
31
+ with sb.logs() as stream:
32
+ for line in stream:
33
+ print(line)
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ | Variable | Purpose |
39
+ | --- | --- |
40
+ | `ORCHARD_API_URL` | Control-plane URL, e.g. `https://orchard.example.com` |
41
+ | `ORCHARD_API_KEY` | API key (`orc_…`) or JWT access token |
42
+ | `ORCHARD_ORG` | Optional org slug (ignored for API keys) |
43
+ | `ORCHARD_WS` | Optional workspace slug |
44
+
45
+ Explicit arguments to `Orchard(...)` win over the environment. The client holds a connection pool: use it as a context manager or call `close()`.
46
+
47
+ ## Live channels
48
+
49
+ Channels are context-managed iterators that work in ordinary synchronous code:
50
+
51
+ ```python
52
+ with sb.shell() as sh:
53
+ sh.send("getprop ro.build.version.release\n")
54
+ for out in sh:
55
+ print(out, end="")
56
+ ```
57
+
58
+ For asyncio code use `orchard_sdk.aio.AsyncChannels`:
59
+
60
+ ```python
61
+ import asyncio
62
+ from orchard_sdk import from_env
63
+ from orchard_sdk.aio import AsyncChannels
64
+
65
+ async def main():
66
+ async for line in AsyncChannels(from_env(), sb.uuid).logs():
67
+ print(line)
68
+
69
+ asyncio.run(main())
70
+ ```
71
+
72
+ Expose a device channel on a local port for ordinary tools:
73
+
74
+ ```python
75
+ with sb.adb_bridge() as bridge:
76
+ print(f"adb connect 127.0.0.1:{bridge.port}")
77
+ ```
78
+
79
+ ## Errors
80
+
81
+ All errors derive from `OrchardError`: `ApiError` (HTTP status + server detail), `SelectorError` (zero or ambiguous sandbox match), `ChannelClosedError` (WebSocket closed with 4401/4403/4404/4502).
82
+
83
+ ```python
84
+ from orchard_sdk import ApiError, SelectorError
85
+
86
+ try:
87
+ orchard.sandbox("pixel").start()
88
+ except SelectorError as e:
89
+ print(e)
90
+ except ApiError as e:
91
+ print(e.status, e.detail)
92
+ ```
93
+
94
+ ## Documentation
95
+
96
+ - [API reference](https://github.com/tryorchard/sdk/blob/main/python/docs/reference.md): every method, with examples
97
+ - [Examples](https://github.com/tryorchard/sdk/tree/main/python/examples): install and launch, UI test with snapshot rollback, frida tracing, traffic capture, adb bridge, scoped keys, log streaming
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,37 @@
1
+ """Orchard SDK — a Python client for the Orchard mobile-sandbox control plane.
2
+
3
+ Quickstart::
4
+
5
+ from orchard_sdk import Orchard
6
+
7
+ orchard = Orchard.from_env() # ORCHARD_API_URL + ORCHARD_API_KEY
8
+ for s in orchard.sandboxes.list()["instances"]:
9
+ print(s["name"], s["state"])
10
+
11
+ sb = orchard.sandbox("my-pixel") # by uuid / uuid-prefix / name / tag
12
+ sb.tag("payments", "#22c55e")
13
+ with sb.logs() as stream:
14
+ for line in stream:
15
+ print(line)
16
+
17
+ See https://docs.orchard.airoverflow.com for guides and the full API reference.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from ._bridge import LocalBridge
22
+ from ._version import __version__
23
+ from .client import Orchard
24
+ from .config import Config, from_env
25
+ from .errors import ApiError, ChannelClosedError, OrchardError, SelectorError
26
+
27
+ __all__ = [
28
+ "Orchard",
29
+ "Config",
30
+ "from_env",
31
+ "OrchardError",
32
+ "ApiError",
33
+ "SelectorError",
34
+ "ChannelClosedError",
35
+ "LocalBridge",
36
+ "__version__",
37
+ ]
@@ -0,0 +1,121 @@
1
+ """A local TCP listener bridged to a device WebSocket channel (adb, vnc, screen).
2
+
3
+ This lets ordinary local tools reach a sandbox as if it were plugged in: the
4
+ bridge opens a port and, for each TCP connection, opens a fresh channel to the
5
+ device and pumps bytes both ways. So ``adb connect 127.0.0.1:<port>`` talks to
6
+ the sandbox's adb, and a VNC viewer to its screen.
7
+
8
+ It runs its own asyncio loop on a daemon thread, like :class:`SyncChannel`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import threading
14
+ from typing import Any
15
+
16
+ from . import _ws
17
+ from .config import Config
18
+
19
+
20
+ class LocalBridge:
21
+ """A local ``host:port`` listener forwarding to a device channel. Start it
22
+ (or use it as a context manager); read :attr:`port`; :meth:`close` when done.
23
+
24
+ >>> with sandbox.adb_bridge() as b:
25
+ ... os.system(f"adb connect 127.0.0.1:{b.port}")
26
+ """
27
+
28
+ def __init__(self, cfg: Config, uuid: str, suffix: str, host: str = "127.0.0.1",
29
+ port: int = 0, **params: Any):
30
+ self._url = _ws.ws_url(cfg, uuid, suffix, **params)
31
+ self.host = host
32
+ self._req_port = port
33
+ #: The bound local port (set once started; the OS picks one if port was 0).
34
+ self.port = 0
35
+ self._loop: asyncio.AbstractEventLoop | None = None
36
+ self._thread: threading.Thread | None = None
37
+ self._server: asyncio.AbstractServer | None = None
38
+ self._ready = threading.Event()
39
+ self._err: BaseException | None = None
40
+
41
+ def start(self) -> "LocalBridge":
42
+ """Open the listener and return self once it is bound."""
43
+ self._thread = threading.Thread(target=self._run, daemon=True)
44
+ self._thread.start()
45
+ if not self._ready.wait(timeout=15):
46
+ raise RuntimeError("bridge did not start within 15s")
47
+ if self._err is not None:
48
+ raise self._err
49
+ return self
50
+
51
+ def __enter__(self) -> "LocalBridge":
52
+ return self.start()
53
+
54
+ def __exit__(self, *exc: object) -> None:
55
+ self.close()
56
+
57
+ def _run(self) -> None:
58
+ self._loop = asyncio.new_event_loop()
59
+ asyncio.set_event_loop(self._loop)
60
+ try:
61
+ self._loop.run_until_complete(self._serve())
62
+ except BaseException as e: # surfaced to start() via _ready/_err
63
+ self._err = e
64
+ self._ready.set()
65
+ finally:
66
+ self._loop.close()
67
+
68
+ async def _serve(self) -> None:
69
+ self._server = await asyncio.start_server(self._handle, self.host, self._req_port)
70
+ self.port = self._server.sockets[0].getsockname()[1]
71
+ self._ready.set()
72
+ async with self._server:
73
+ await self._server.serve_forever()
74
+
75
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
76
+ try:
77
+ async with _ws.connect(self._url) as ws:
78
+ await self._pump(ws, reader, writer)
79
+ except Exception:
80
+ with _suppress():
81
+ writer.close()
82
+
83
+ @staticmethod
84
+ async def _pump(ws: Any, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
85
+ async def to_ws() -> None:
86
+ try:
87
+ while chunk := await reader.read(65536):
88
+ await ws.send(chunk)
89
+ except Exception:
90
+ pass
91
+ finally:
92
+ with _suppress():
93
+ await ws.close()
94
+
95
+ async def to_local() -> None:
96
+ try:
97
+ async for msg in ws:
98
+ writer.write(msg if isinstance(msg, (bytes, bytearray)) else msg.encode())
99
+ await writer.drain()
100
+ except Exception:
101
+ pass
102
+ finally:
103
+ with _suppress():
104
+ writer.close()
105
+
106
+ await asyncio.gather(to_ws(), to_local(), return_exceptions=True)
107
+
108
+ def close(self) -> None:
109
+ """Stop the listener and join the worker thread."""
110
+ if self._loop is not None and self._loop.is_running():
111
+ self._loop.call_soon_threadsafe(self._loop.stop)
112
+ if self._thread is not None:
113
+ self._thread.join(timeout=5)
114
+
115
+
116
+ class _suppress:
117
+ def __enter__(self) -> None:
118
+ return None
119
+
120
+ def __exit__(self, *exc: object) -> bool:
121
+ return True # swallow any exception in the with-body
@@ -0,0 +1,191 @@
1
+ """Synchronous wrappers over the async WebSocket core.
2
+
3
+ :class:`SyncChannel` lets ordinary (non-asyncio) code consume a live channel as
4
+ a plain iterator::
5
+
6
+ with sandbox.logs() as stream:
7
+ for line in stream:
8
+ print(line)
9
+
10
+ It runs a private asyncio loop on a daemon thread, pushes decoded frames onto a
11
+ queue, and shuts the loop down cleanly when the ``with`` block exits — including
12
+ on ``break`` or Ctrl-C. :class:`Tunnel` drives the reverse-tunnel control channel.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import threading
19
+ from queue import Queue
20
+ from typing import Any, Callable, Iterator
21
+
22
+ from . import _ws
23
+ from .config import Config
24
+
25
+ _SENTINEL = object()
26
+
27
+
28
+ class SyncChannel:
29
+ """A live WebSocket channel exposed as a context-managed, synchronous iterator.
30
+
31
+ Args:
32
+ url: The fully-built channel URL (see :func:`orchard_sdk._ws.ws_url`).
33
+ decode: Per-frame decoder (text/json/bytes). Defaults to text.
34
+
35
+ The channel is lazy: the socket opens on ``__enter__`` and closes on
36
+ ``__exit__``. Iterating yields decoded frames until the stream ends or the
37
+ block is left. ``send()`` pushes data upstream (e.g. shell keystrokes).
38
+ """
39
+
40
+ def __init__(self, url: str, decode: Callable[[str | bytes], Any] = _ws.decode_text,
41
+ queue_max: int = 0, open_send: str | bytes | None = None):
42
+ self._url = url
43
+ self._decode = decode
44
+ self._open_send = open_send
45
+ self._queue: "Queue[Any]" = Queue(maxsize=queue_max)
46
+ self._loop: asyncio.AbstractEventLoop | None = None
47
+ self._thread: threading.Thread | None = None
48
+ self._ws: Any = None
49
+ self._stop = False
50
+ self._exc: BaseException | None = None
51
+
52
+ # -- lifecycle --------------------------------------------------------
53
+ def __enter__(self) -> "SyncChannel":
54
+ self._thread = threading.Thread(target=self._run, daemon=True)
55
+ self._thread.start()
56
+ return self
57
+
58
+ def __exit__(self, *exc: object) -> None:
59
+ self.close()
60
+
61
+ def _run(self) -> None:
62
+ self._loop = asyncio.new_event_loop()
63
+ asyncio.set_event_loop(self._loop)
64
+ try:
65
+ self._loop.run_until_complete(self._pump())
66
+ except BaseException as e: # surfaced to the consumer thread via the queue
67
+ self._exc = e
68
+ finally:
69
+ self._queue.put(_SENTINEL)
70
+ self._loop.close()
71
+
72
+ async def _pump(self) -> None:
73
+ async with _ws.connect(self._url) as ws:
74
+ self._ws = ws
75
+ if self._open_send is not None: # e.g. a frida script sent on connect
76
+ await ws.send(self._open_send)
77
+ try:
78
+ async for frame in ws:
79
+ if self._stop:
80
+ break
81
+ self._queue.put(self._decode(frame))
82
+ except Exception as e: # ConnectionClosed w/ error code → re-raised to consumer
83
+ import websockets
84
+ if isinstance(e, websockets.ConnectionClosed):
85
+ err = _ws._closed_err(e)
86
+ if err is not None:
87
+ raise err from None
88
+ elif not self._stop:
89
+ raise
90
+
91
+ # -- consumer API -----------------------------------------------------
92
+ def __iter__(self) -> Iterator[Any]:
93
+ while True:
94
+ item = self._queue.get()
95
+ if item is _SENTINEL:
96
+ if self._exc is not None:
97
+ raise self._exc
98
+ return
99
+ yield item
100
+
101
+ def send(self, data: str | bytes) -> None:
102
+ """Send a frame upstream (e.g. a shell keystroke or a resize JSON)."""
103
+ if self._loop is None or self._ws is None:
104
+ raise RuntimeError("channel not open")
105
+ fut = asyncio.run_coroutine_threadsafe(self._ws.send(data), self._loop)
106
+ fut.result(timeout=10)
107
+
108
+ def close(self) -> None:
109
+ """Stop the stream and join the worker thread."""
110
+ self._stop = True
111
+ if self._ws is not None and self._loop is not None and self._loop.is_running():
112
+ try:
113
+ asyncio.run_coroutine_threadsafe(self._ws.close(), self._loop).result(timeout=5)
114
+ except Exception:
115
+ pass
116
+ if self._thread is not None:
117
+ self._thread.join(timeout=5)
118
+
119
+
120
+ class Tunnel:
121
+ """The reverse-tunnel control channel: forwards guest proxy connections into a
122
+ local proxy you run (Burp/mitmproxy). Context-managed; blocks in :meth:`run`.
123
+ """
124
+
125
+ def __init__(self, cfg: Config, uuid: str, to_host: str, to_port: int):
126
+ self._cfg = cfg
127
+ self._uuid = uuid
128
+ self._to = (to_host, to_port)
129
+
130
+ def run(self, on_event: Callable[[str, str], None] | None = None) -> int:
131
+ """Hold the tunnel open until the server closes it. ``on_event(level, text)``
132
+ receives ``ready``/``open``/``warn``/``error``/``close`` notifications."""
133
+ log = on_event or (lambda level, text: None)
134
+ return asyncio.run(self._run(log))
135
+
136
+ async def _run(self, log: Callable[[str, str], None]) -> int:
137
+ tasks: set[asyncio.Task] = set()
138
+ to_host, to_port = self._to
139
+
140
+ async def data(sid: str) -> None:
141
+ try:
142
+ async with _ws.connect(_ws.ws_url(self._cfg, self._uuid, "tunnel/data", sid=sid)) as ws:
143
+ reader, writer = await asyncio.open_connection(to_host, to_port)
144
+ await _pump(ws, reader, writer)
145
+ except Exception as e:
146
+ log("warn", f"stream {sid[:8]} ended: {e}")
147
+
148
+ async with _ws.connect(_ws.ws_url(self._cfg, self._uuid, "tunnel")) as control:
149
+ async for raw in control:
150
+ try:
151
+ msg = json.loads(raw)
152
+ except Exception:
153
+ continue
154
+ t = msg.get("t")
155
+ if t == "ready":
156
+ log("ready", f"guest proxy -> {msg.get('proxy_target')}")
157
+ elif t == "open":
158
+ task = asyncio.create_task(data(msg["sid"]))
159
+ tasks.add(task)
160
+ task.add_done_callback(tasks.discard)
161
+ elif t == "warn":
162
+ log("warn", msg.get("detail", ""))
163
+ elif t == "error":
164
+ log("error", msg.get("detail", "tunnel refused"))
165
+ return 1
166
+ elif t == "close":
167
+ log("close", "closed by server")
168
+ return 0
169
+ return 0
170
+
171
+
172
+ async def _pump(ws: Any, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
173
+ async def to_ws() -> None:
174
+ try:
175
+ while chunk := await reader.read(65536):
176
+ await ws.send(chunk)
177
+ finally:
178
+ await ws.close()
179
+
180
+ async def to_local() -> None:
181
+ try:
182
+ async for msg in ws:
183
+ writer.write(msg if isinstance(msg, (bytes, bytearray)) else msg.encode())
184
+ await writer.drain()
185
+ finally:
186
+ try:
187
+ writer.close()
188
+ except Exception:
189
+ pass
190
+
191
+ await asyncio.gather(to_ws(), to_local(), return_exceptions=True)