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.
- tryorchard_sdk-0.0.1/LICENSE +21 -0
- tryorchard_sdk-0.0.1/PKG-INFO +137 -0
- tryorchard_sdk-0.0.1/README.md +101 -0
- tryorchard_sdk-0.0.1/orchard_sdk/__init__.py +37 -0
- tryorchard_sdk-0.0.1/orchard_sdk/_bridge.py +121 -0
- tryorchard_sdk-0.0.1/orchard_sdk/_channels.py +191 -0
- tryorchard_sdk-0.0.1/orchard_sdk/_http.py +100 -0
- tryorchard_sdk-0.0.1/orchard_sdk/_version.py +1 -0
- tryorchard_sdk-0.0.1/orchard_sdk/_ws.py +92 -0
- tryorchard_sdk-0.0.1/orchard_sdk/aio/__init__.py +6 -0
- tryorchard_sdk-0.0.1/orchard_sdk/aio/channels.py +57 -0
- tryorchard_sdk-0.0.1/orchard_sdk/client.py +82 -0
- tryorchard_sdk-0.0.1/orchard_sdk/config.py +69 -0
- tryorchard_sdk-0.0.1/orchard_sdk/errors.py +50 -0
- tryorchard_sdk-0.0.1/orchard_sdk/models.py +139 -0
- tryorchard_sdk-0.0.1/orchard_sdk/py.typed +0 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/__init__.py +4 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/keys.py +46 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/misc.py +111 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/remote.py +88 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/sandboxes.py +432 -0
- tryorchard_sdk-0.0.1/orchard_sdk/resources/team.py +108 -0
- tryorchard_sdk-0.0.1/orchard_sdk/selector.py +53 -0
- tryorchard_sdk-0.0.1/pyproject.toml +52 -0
- tryorchard_sdk-0.0.1/setup.cfg +4 -0
- tryorchard_sdk-0.0.1/tests/test_config.py +31 -0
- tryorchard_sdk-0.0.1/tests/test_http_selector.py +54 -0
- tryorchard_sdk-0.0.1/tests/test_resources.py +128 -0
- tryorchard_sdk-0.0.1/tests/test_smoke_live.py +29 -0
- tryorchard_sdk-0.0.1/tests/test_ws.py +54 -0
- tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/PKG-INFO +137 -0
- tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/SOURCES.txt +33 -0
- tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/dependency_links.txt +1 -0
- tryorchard_sdk-0.0.1/tryorchard_sdk.egg-info/requires.txt +12 -0
- 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)
|