tryorchard-sdk 0.0.1__py3-none-any.whl
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.
- orchard_sdk/__init__.py +37 -0
- orchard_sdk/_bridge.py +121 -0
- orchard_sdk/_channels.py +191 -0
- orchard_sdk/_http.py +100 -0
- orchard_sdk/_version.py +1 -0
- orchard_sdk/_ws.py +92 -0
- orchard_sdk/aio/__init__.py +6 -0
- orchard_sdk/aio/channels.py +57 -0
- orchard_sdk/client.py +82 -0
- orchard_sdk/config.py +69 -0
- orchard_sdk/errors.py +50 -0
- orchard_sdk/models.py +139 -0
- orchard_sdk/py.typed +0 -0
- orchard_sdk/resources/__init__.py +4 -0
- orchard_sdk/resources/keys.py +46 -0
- orchard_sdk/resources/misc.py +111 -0
- orchard_sdk/resources/remote.py +88 -0
- orchard_sdk/resources/sandboxes.py +432 -0
- orchard_sdk/resources/team.py +108 -0
- orchard_sdk/selector.py +53 -0
- tryorchard_sdk-0.0.1.dist-info/METADATA +137 -0
- tryorchard_sdk-0.0.1.dist-info/RECORD +25 -0
- tryorchard_sdk-0.0.1.dist-info/WHEEL +5 -0
- tryorchard_sdk-0.0.1.dist-info/licenses/LICENSE +21 -0
- tryorchard_sdk-0.0.1.dist-info/top_level.txt +1 -0
orchard_sdk/__init__.py
ADDED
|
@@ -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
|
+
]
|
orchard_sdk/_bridge.py
ADDED
|
@@ -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
|
orchard_sdk/_channels.py
ADDED
|
@@ -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)
|
orchard_sdk/_http.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The sync REST transport: one httpx client that knows Orchard's base URL,
|
|
2
|
+
auth header, tenancy headers, and how a server error becomes an :class:`ApiError`.
|
|
3
|
+
|
|
4
|
+
Internal — consumers use :class:`orchard_sdk.Orchard` and its resource namespaces,
|
|
5
|
+
not this class directly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .errors import ApiError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _detail(r: httpx.Response) -> str:
|
|
19
|
+
try:
|
|
20
|
+
body = r.json()
|
|
21
|
+
except Exception:
|
|
22
|
+
return r.text[:300].strip()
|
|
23
|
+
if isinstance(body, dict):
|
|
24
|
+
return str(body.get("detail") or body)
|
|
25
|
+
return str(body)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpClient:
|
|
29
|
+
"""Thin synchronous wrapper over ``httpx.Client`` scoped to one Orchard org/workspace."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, cfg: Config):
|
|
32
|
+
self.cfg = cfg
|
|
33
|
+
headers = {"Authorization": f"Bearer {cfg.token}"}
|
|
34
|
+
if cfg.org:
|
|
35
|
+
headers["X-Orchard-Org"] = cfg.org
|
|
36
|
+
if cfg.ws:
|
|
37
|
+
headers["X-Orchard-Workspace"] = cfg.ws
|
|
38
|
+
self._c = httpx.Client(base_url=cfg.api_base, headers=headers, timeout=cfg.timeout)
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
self._c.close()
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> "HttpClient":
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *exc: object) -> None:
|
|
47
|
+
self.close()
|
|
48
|
+
|
|
49
|
+
def _request(self, method: str, path: str, **kw: Any) -> Any:
|
|
50
|
+
r = self._c.request(method, path, **kw)
|
|
51
|
+
if r.status_code >= 400:
|
|
52
|
+
raise ApiError(r.status_code, _detail(r))
|
|
53
|
+
if r.status_code == 204 or not r.content:
|
|
54
|
+
return None
|
|
55
|
+
return r.json()
|
|
56
|
+
|
|
57
|
+
def get(self, path: str, **kw: Any) -> Any:
|
|
58
|
+
return self._request("GET", path, **kw)
|
|
59
|
+
|
|
60
|
+
def get_content(self, path: str, **kw: Any) -> bytes:
|
|
61
|
+
"""A GET whose body is raw bytes (screenshots, mitm CA)."""
|
|
62
|
+
r = self._c.request("GET", path, **kw)
|
|
63
|
+
if r.status_code >= 400:
|
|
64
|
+
raise ApiError(r.status_code, _detail(r))
|
|
65
|
+
return r.content
|
|
66
|
+
|
|
67
|
+
def post(self, path: str, json: Any = None, **kw: Any) -> Any:
|
|
68
|
+
return self._request("POST", path, json=json, **kw)
|
|
69
|
+
|
|
70
|
+
def patch(self, path: str, json: Any = None, **kw: Any) -> Any:
|
|
71
|
+
return self._request("PATCH", path, json=json, **kw)
|
|
72
|
+
|
|
73
|
+
def delete(self, path: str, **kw: Any) -> Any:
|
|
74
|
+
return self._request("DELETE", path, **kw)
|
|
75
|
+
|
|
76
|
+
def post_files(self, path: str, files: list[tuple[str, Path]],
|
|
77
|
+
data: dict[str, Any] | None = None) -> Any:
|
|
78
|
+
"""multipart POST (APK/IPA install, file upload). ``files`` is ``[(field, Path)]``."""
|
|
79
|
+
handles = [(field, (p.name, p.open("rb"))) for field, p in files]
|
|
80
|
+
try:
|
|
81
|
+
return self._request("POST", path, files=handles, data=data or {})
|
|
82
|
+
finally:
|
|
83
|
+
for _, (_, fh) in handles:
|
|
84
|
+
fh.close()
|
|
85
|
+
|
|
86
|
+
def download(self, absolute_url: str, dest: Path) -> int:
|
|
87
|
+
"""Stream a signed-grant URL (full URL incl. ``?token=``) to a file.
|
|
88
|
+
|
|
89
|
+
The grant is itself the credential, so this request carries **no**
|
|
90
|
+
Authorization header. Returns the number of bytes written.
|
|
91
|
+
"""
|
|
92
|
+
total = 0
|
|
93
|
+
with httpx.Client(timeout=300.0) as raw, raw.stream("GET", absolute_url) as r:
|
|
94
|
+
if r.status_code >= 400:
|
|
95
|
+
raise ApiError(r.status_code, _detail(r))
|
|
96
|
+
with dest.open("wb") as fh:
|
|
97
|
+
for chunk in r.iter_bytes(65536):
|
|
98
|
+
fh.write(chunk)
|
|
99
|
+
total += len(chunk)
|
|
100
|
+
return total
|
orchard_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
orchard_sdk/_ws.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Async WebSocket core: URL assembly + a decoded frame iterator.
|
|
2
|
+
|
|
3
|
+
A *channel* is one of Orchard's per-sandbox WebSocket streams (logs, shell,
|
|
4
|
+
screen, adb, vnc, mitm, …). Frames arrive as text or binary depending on the
|
|
5
|
+
channel; :func:`channel` decodes each frame with the caller-supplied ``decode``.
|
|
6
|
+
Used directly by :mod:`orchard_sdk.aio`; :mod:`orchard_sdk._channels` wraps it
|
|
7
|
+
for synchronous callers.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json as _json
|
|
12
|
+
from typing import Any, AsyncIterator, Callable
|
|
13
|
+
from urllib.parse import urlencode
|
|
14
|
+
|
|
15
|
+
import websockets
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .errors import ChannelClosedError
|
|
19
|
+
|
|
20
|
+
# Close codes the control plane emits.
|
|
21
|
+
_ERR_CODES = {4401, 4403, 4404, 4502}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _closed_err(exc: "websockets.ConnectionClosed") -> ChannelClosedError | None:
|
|
25
|
+
"""Map a ConnectionClosed to a ChannelClosedError when the code is an Orchard
|
|
26
|
+
error code, else None (a normal end-of-stream). Reads the close frame
|
|
27
|
+
directly to avoid the deprecated ``.code``/``.reason`` shims."""
|
|
28
|
+
frame = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
|
|
29
|
+
code = frame.code if frame is not None else 1006
|
|
30
|
+
if code in _ERR_CODES:
|
|
31
|
+
return ChannelClosedError(code, (frame.reason if frame is not None else "") or "")
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ws_url(cfg: Config, uuid: str, suffix: str, **extra: Any) -> str:
|
|
36
|
+
"""Build ``wss?://host/ws/sandboxes/<uuid>/<suffix>?token=…`` with auth + extras.
|
|
37
|
+
|
|
38
|
+
``extra`` values that are ``None`` are omitted (e.g. ``sid=None``).
|
|
39
|
+
"""
|
|
40
|
+
q: dict[str, Any] = {"token": cfg.token}
|
|
41
|
+
if cfg.org:
|
|
42
|
+
q["org"] = cfg.org
|
|
43
|
+
if cfg.ws:
|
|
44
|
+
q["ws"] = cfg.ws
|
|
45
|
+
q.update({k: v for k, v in extra.items() if v is not None})
|
|
46
|
+
return f"{cfg.ws_base}/ws/sandboxes/{uuid}/{suffix}?{urlencode(q)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def raw_ws_url(cfg: Config, path: str, **extra: Any) -> str:
|
|
50
|
+
"""Build a WS URL for a non-sandbox path (e.g. ``ws/jobs/…``, ``ws/remote/…``)."""
|
|
51
|
+
q: dict[str, Any] = {"token": cfg.token}
|
|
52
|
+
if cfg.org:
|
|
53
|
+
q["org"] = cfg.org
|
|
54
|
+
if cfg.ws:
|
|
55
|
+
q["ws"] = cfg.ws
|
|
56
|
+
q.update({k: v for k, v in extra.items() if v is not None})
|
|
57
|
+
return f"{cfg.ws_base}/{path.lstrip('/')}?{urlencode(q)}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def connect(url: str):
|
|
61
|
+
"""Open a client connection with the raw-transport settings binary channels need."""
|
|
62
|
+
return websockets.connect(url, max_size=None, open_timeout=15)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def decode_text(frame: str | bytes) -> str:
|
|
66
|
+
return frame if isinstance(frame, str) else frame.decode(errors="replace")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode_json(frame: str | bytes) -> Any:
|
|
70
|
+
return _json.loads(frame)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_bytes(frame: str | bytes) -> bytes:
|
|
74
|
+
return frame if isinstance(frame, (bytes, bytearray)) else frame.encode()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def channel(url: str, decode: Callable[[str | bytes], Any] = decode_text
|
|
78
|
+
) -> AsyncIterator[Any]:
|
|
79
|
+
"""Async-iterate decoded frames from ``url`` until the socket closes cleanly.
|
|
80
|
+
|
|
81
|
+
Raises :class:`ChannelClosedError` if the server closes with an error code
|
|
82
|
+
(4401/4403/4404/4502).
|
|
83
|
+
"""
|
|
84
|
+
async with connect(url) as ws:
|
|
85
|
+
try:
|
|
86
|
+
async for frame in ws:
|
|
87
|
+
yield decode(frame)
|
|
88
|
+
except websockets.ConnectionClosed as exc:
|
|
89
|
+
err = _closed_err(exc)
|
|
90
|
+
if err is not None:
|
|
91
|
+
raise err from None
|
|
92
|
+
# 1000/1001/etc. are a normal end-of-stream.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Async WebSocket channels for asyncio callers.
|
|
2
|
+
|
|
3
|
+
The synchronous :class:`orchard_sdk.resources.sandboxes.Sandbox` channels wrap
|
|
4
|
+
these behind a thread. If you are already in an event loop, use these directly::
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from orchard_sdk import from_env
|
|
8
|
+
from orchard_sdk.aio import AsyncChannels
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
cfg = from_env()
|
|
12
|
+
async for line in AsyncChannels(cfg, "<uuid>").logs():
|
|
13
|
+
print(line)
|
|
14
|
+
|
|
15
|
+
asyncio.run(main())
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, AsyncIterator
|
|
20
|
+
|
|
21
|
+
from .. import _ws
|
|
22
|
+
from ..config import Config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncChannels:
|
|
26
|
+
"""Async-iterable live channels for one sandbox uuid."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, cfg: Config, uuid: str):
|
|
29
|
+
self._cfg = cfg
|
|
30
|
+
self._uuid = uuid
|
|
31
|
+
|
|
32
|
+
def _url(self, suffix: str, **extra: Any) -> str:
|
|
33
|
+
return _ws.ws_url(self._cfg, self._uuid, suffix, **extra)
|
|
34
|
+
|
|
35
|
+
def logs(self) -> AsyncIterator[str]:
|
|
36
|
+
"""Async-iterate log lines."""
|
|
37
|
+
return _ws.channel(self._url("logs"), _ws.decode_text)
|
|
38
|
+
|
|
39
|
+
def shell(self) -> AsyncIterator[str]:
|
|
40
|
+
"""Async-iterate shell output (read-only here; use the raw socket to send)."""
|
|
41
|
+
return _ws.channel(self._url("shell"), _ws.decode_text)
|
|
42
|
+
|
|
43
|
+
def screen(self, source: str = "scrcpy") -> AsyncIterator[bytes]:
|
|
44
|
+
"""Async-iterate raw screen frames."""
|
|
45
|
+
return _ws.channel(self._url("screen/stream", source=source), _ws.decode_bytes)
|
|
46
|
+
|
|
47
|
+
def adb(self) -> AsyncIterator[bytes]:
|
|
48
|
+
"""Async-iterate raw adb transport bytes."""
|
|
49
|
+
return _ws.channel(self._url("adb"), _ws.decode_bytes)
|
|
50
|
+
|
|
51
|
+
def vnc(self) -> AsyncIterator[bytes]:
|
|
52
|
+
"""Async-iterate raw RFB bytes (iOS VM)."""
|
|
53
|
+
return _ws.channel(self._url("vnc"), _ws.decode_bytes)
|
|
54
|
+
|
|
55
|
+
def mitm_flows(self) -> AsyncIterator[Any]:
|
|
56
|
+
"""Async-iterate captured flow summaries (decoded JSON)."""
|
|
57
|
+
return _ws.channel(self._url("mitm/flows"), _ws.decode_json)
|