RemoteRF 2.3.1__tar.gz → 2.4.0__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.
- {remoterf-2.3.1/src/remoterf.egg-info → remoterf-2.4.0}/PKG-INFO +1 -1
- {remoterf-2.3.1 → remoterf-2.4.0}/pyproject.toml +1 -1
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/direct_path.py +162 -25
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/grpc_client.py +55 -9
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/backend.py +50 -25
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/http.py +3 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/dynamic_device.py +16 -0
- {remoterf-2.3.1 → remoterf-2.4.0/src/remoterf.egg-info}/PKG-INFO +1 -1
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/SOURCES.txt +1 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_device_routes.py +1 -0
- remoterf-2.4.0/tests/test_direct_path_global.py +139 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_partner.py +6 -5
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_dynamic_device_codegen.py +2 -1
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_federated_devices.py +60 -0
- remoterf-2.4.0/tests/test_fetch_idl_fallback.py +98 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_gate_i_direct_path.py +119 -7
- remoterf-2.3.1/tests/test_fetch_idl_fallback.py +0 -45
- {remoterf-2.3.1 → remoterf-2.4.0}/LICENSE +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/README.md +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/setup.cfg +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/banner.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/common/utils/process_arg.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/config/config.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/app.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/dynamic_v2_transport.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/grpc_acc.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/direct.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/homes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/remoterf_cli.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf/version.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/requires.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.egg-info/top_level.txt +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/src/remoterf.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_banner.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_cli_login_fresh.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_config_address.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_deployment_accounts.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_direct_path_race.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_finish_prompt.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_homes.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_native_devices.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_process_arg.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_ti_mmwave.py +0 -0
- {remoterf-2.3.1 → remoterf-2.4.0}/tests/test_upgrade_rescue.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remoterf
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
|
|
5
5
|
Author-email: Ethan Ge <ethoGalaxy@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "remoterf"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "GPL-3.0-or-later"
|
|
@@ -19,9 +19,11 @@ On a home reached over its relay, ICE (aioice) punches one UDP pair to the
|
|
|
19
19
|
Server and QUIC (aioquic) runs over it; device RPCs then travel as one stream
|
|
20
20
|
per call (4-byte big-endian length + GenericRPCRequest, reply likewise)
|
|
21
21
|
instead of through the relay. Control RPCs (ACC:*) stay on gRPC. When the
|
|
22
|
-
punch fails or the path dies, calls fall back to gRPC
|
|
23
|
-
|
|
24
|
-
has. See
|
|
22
|
+
punch fails or the path dies, calls fall back to gRPC; a path that died is
|
|
23
|
+
retried after 30 s, a failed attempt only once a device call wants it.
|
|
24
|
+
Signaling is ACC:direct_offer over the channel the client already has. See
|
|
25
|
+
RemoteRF-Server docs/gate-i-direct-path.md. A Global device ref or rfg_
|
|
26
|
+
token signals through Global instead (global_path).
|
|
25
27
|
|
|
26
28
|
A partner lab's device (one the home lists on a federation agreement) gets a
|
|
27
29
|
path of its own, punched to that lab: the offer goes to the home with the
|
|
@@ -42,6 +44,7 @@ import asyncio
|
|
|
42
44
|
import atexit
|
|
43
45
|
import collections
|
|
44
46
|
import contextlib
|
|
47
|
+
import hashlib
|
|
45
48
|
import json
|
|
46
49
|
import os
|
|
47
50
|
import secrets
|
|
@@ -74,6 +77,8 @@ FIXED_SECONDS = 2.0 # a reachable fixed port answers in one round trip; a filte
|
|
|
74
77
|
SETTLE_SECONDS = 20.0 # gathering, the offer RPC and both budgets: one attempt, worst case
|
|
75
78
|
WAIT_SECONDS = 3.0 # how long a login or a first device call waits for that attempt
|
|
76
79
|
RETRY_SECONDS = 30.0
|
|
80
|
+
MEMO_SECONDS = 600 # a route that failed here is not signaled again for this long, by any process
|
|
81
|
+
MEMO_FILE = "direct-v1.json"
|
|
77
82
|
IDLE_SECONDS = 600
|
|
78
83
|
SLEEP_CHECK_SECONDS = 5.0 # how often the path looks for a system sleep it slept through
|
|
79
84
|
async def close_ice(ice) -> None:
|
|
@@ -202,6 +207,53 @@ class PathError(Exception):
|
|
|
202
207
|
"""The direct path could not carry this call and is down now."""
|
|
203
208
|
|
|
204
209
|
|
|
210
|
+
class PathUnavailable(PathError):
|
|
211
|
+
"""This route can never carry a direct path: stop trying."""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---- what the last attempt per (route, network) came to, across processes ----
|
|
215
|
+
#
|
|
216
|
+
# Keys are hashes: no ref, token or address is stored in clear.
|
|
217
|
+
|
|
218
|
+
def _memo_key(scope: str) -> str:
|
|
219
|
+
return hashlib.sha256(f"{scope}|{default_route_address()}".encode()).hexdigest()[:32]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _memo_entries() -> dict:
|
|
223
|
+
from ..deployment.state import root
|
|
224
|
+
|
|
225
|
+
memo = json.loads((root() / MEMO_FILE).read_text())
|
|
226
|
+
now = time.time()
|
|
227
|
+
return {key: [str(value[0]), int(value[1])] for key, value in memo.items() if now - value[1] < 86400}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _memo_outcome(scope: Optional[str]) -> Optional[tuple[str, int]]:
|
|
231
|
+
"""("ready" | "failed" | "unavailable", when) for ``scope`` on this network, if known."""
|
|
232
|
+
if scope is None:
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
outcome, at = _memo_entries()[_memo_key(scope)]
|
|
236
|
+
return outcome, at
|
|
237
|
+
except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _remember(scope: Optional[str], outcome: str) -> None:
|
|
242
|
+
if scope is None:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
from ..deployment.state import private_write, root
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
memo = _memo_entries()
|
|
249
|
+
except Exception: # noqa: BLE001 - none yet, or unreadable: start over
|
|
250
|
+
memo = {}
|
|
251
|
+
memo[_memo_key(scope)] = [outcome, int(time.time())]
|
|
252
|
+
private_write(root() / MEMO_FILE, memo)
|
|
253
|
+
except Exception: # noqa: BLE001 - ponytail: a lost memo costs one extra attempt
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
|
|
205
257
|
def _why(exc: BaseException) -> str:
|
|
206
258
|
return "timed out" if isinstance(exc, asyncio.TimeoutError) else f"{type(exc).__name__}: {exc}"
|
|
207
259
|
|
|
@@ -257,14 +309,18 @@ class DirectPath:
|
|
|
257
309
|
``offer`` sends the ICE offer dict and returns the answer dict (it runs
|
|
258
310
|
the ACC:direct_offer RPC, blocking). ``server_name`` is the hostname the
|
|
259
311
|
Server's certificate must carry; ``ca_pem`` the CA pinned for this home.
|
|
312
|
+
``memo`` names the route in the cross-process record of how the last
|
|
313
|
+
attempt on this network went (None: no record, and calls always wait).
|
|
260
314
|
"""
|
|
261
315
|
|
|
262
316
|
def __init__(self, *, offer: Callable[[dict], dict], server_name: str, ca_pem: bytes,
|
|
263
|
-
stun: Optional[tuple[str, int]] = STUN):
|
|
317
|
+
stun: Optional[tuple[str, int]] = STUN, memo: Optional[str] = None):
|
|
264
318
|
self._offer = offer
|
|
265
319
|
self._server_name = server_name
|
|
266
320
|
self._ca_pem = ca_pem
|
|
267
321
|
self._stun = stun
|
|
322
|
+
self._memo = memo
|
|
323
|
+
self._answered = False # this attempt's offer was answered: a failure now is the network's
|
|
268
324
|
self._id = secrets.token_hex(8) # tells this process's path from the session's others
|
|
269
325
|
self._loop = asyncio.new_event_loop()
|
|
270
326
|
quiet_aioice_errors(self._loop)
|
|
@@ -300,11 +356,20 @@ class DirectPath:
|
|
|
300
356
|
"""Block until the first attempt has settled, or ``timeout``; True when ready.
|
|
301
357
|
|
|
302
358
|
A punch that works takes well under a second; one that cannot work
|
|
303
|
-
spends the whole ICE budget, and nobody should wait for that.
|
|
359
|
+
spends the whole ICE budget, and nobody should wait for that. Nor
|
|
360
|
+
for a path that has not worked on this network before.
|
|
304
361
|
"""
|
|
305
|
-
self.
|
|
362
|
+
if self._worth_waiting():
|
|
363
|
+
self._settled.wait(timeout)
|
|
306
364
|
return self.ready
|
|
307
365
|
|
|
366
|
+
def _worth_waiting(self) -> bool:
|
|
367
|
+
"""Only where the last attempt on this network reached ready."""
|
|
368
|
+
if self._memo is None:
|
|
369
|
+
return True
|
|
370
|
+
outcome = _memo_outcome(self._memo)
|
|
371
|
+
return outcome is not None and outcome[0] == "ready"
|
|
372
|
+
|
|
308
373
|
@property
|
|
309
374
|
def ready(self) -> bool:
|
|
310
375
|
return self.state == "ready"
|
|
@@ -342,6 +407,8 @@ class DirectPath:
|
|
|
342
407
|
"""A device call wants the idle path back; with ``wait`` it gives the
|
|
343
408
|
re-punch that long before settling for the relay."""
|
|
344
409
|
self._loop.call_soon_threadsafe(lambda: self._wanted and self._wanted.set())
|
|
410
|
+
if wait and self.state in ("idle", "connecting") and not self._worth_waiting():
|
|
411
|
+
wait = 0.0
|
|
345
412
|
deadline = time.monotonic() + wait
|
|
346
413
|
while wait and not self.ready and self.state in ("idle", "connecting") and time.monotonic() < deadline:
|
|
347
414
|
time.sleep(0.05)
|
|
@@ -368,16 +435,35 @@ class DirectPath:
|
|
|
368
435
|
try:
|
|
369
436
|
while not self._stopping:
|
|
370
437
|
self._dead = asyncio.Event()
|
|
438
|
+
memo = _memo_outcome(self._memo)
|
|
439
|
+
remaining = MEMO_SECONDS - (time.time() - memo[1]) if memo and memo[0] != "ready" else 0
|
|
440
|
+
if remaining > 0:
|
|
441
|
+
# At most one signaling attempt per MEMO_SECONDS on a network
|
|
442
|
+
# that cannot carry the path, whichever process makes it.
|
|
443
|
+
self.state, self.reason = "down", f"the last attempt on this network: {memo[0]}"
|
|
444
|
+
self._settled.set()
|
|
445
|
+
await self._on_demand(remaining)
|
|
446
|
+
continue
|
|
371
447
|
try:
|
|
372
448
|
await asyncio.wait_for(self._establish(), SETTLE_SECONDS)
|
|
373
449
|
except Exception as exc: # noqa: BLE001 - a failed punch is expected on some networks
|
|
374
450
|
self.reason = str(exc) if isinstance(exc, PathError) else _why(exc)
|
|
375
|
-
|
|
376
|
-
if "No matching server function" in self.reason:
|
|
451
|
+
if isinstance(exc, PathUnavailable) or "No matching server function" in self.reason:
|
|
377
452
|
self.state = "unavailable"
|
|
378
|
-
|
|
453
|
+
if not isinstance(exc, PathUnavailable):
|
|
454
|
+
self.reason = "this Server has no direct path"
|
|
455
|
+
_remember(self._memo, "unavailable")
|
|
379
456
|
return
|
|
457
|
+
if self._answered:
|
|
458
|
+
_remember(self._memo, "failed")
|
|
459
|
+
self.state = "down"
|
|
460
|
+
self._settled.set()
|
|
461
|
+
await self._teardown()
|
|
462
|
+
# No polling: the next attempt waits for a device call to want it.
|
|
463
|
+
await self._on_demand(RETRY_SECONDS)
|
|
464
|
+
continue
|
|
380
465
|
else:
|
|
466
|
+
_remember(self._memo, "ready")
|
|
381
467
|
self.state = "ready"
|
|
382
468
|
self._settled.set()
|
|
383
469
|
await self._dead.wait()
|
|
@@ -398,14 +484,19 @@ class DirectPath:
|
|
|
398
484
|
self._settled.set()
|
|
399
485
|
await self._teardown()
|
|
400
486
|
|
|
487
|
+
async def _on_demand(self, delay: float) -> None:
|
|
488
|
+
"""Until a device call wants the path, and no sooner than ``delay``."""
|
|
489
|
+
self._wanted = asyncio.Event()
|
|
490
|
+
await asyncio.sleep(delay)
|
|
491
|
+
await self._wanted.wait()
|
|
492
|
+
|
|
401
493
|
async def _establish(self) -> None:
|
|
402
494
|
loop = asyncio.get_running_loop()
|
|
495
|
+
self._answered = False
|
|
403
496
|
ice = self._ice = Ice(ice_controlling=True, stun_server=self._stun)
|
|
497
|
+
# A silent STUN server still leaves the host candidate: a lab on this
|
|
498
|
+
# machine or LAN, or its fixed port, needs nothing more.
|
|
404
499
|
await ice.gather_candidates()
|
|
405
|
-
if self._stun and not any(c.type == "srflx" for c in ice.local_candidates):
|
|
406
|
-
# Without a reflexive address there is nothing to punch with, and a
|
|
407
|
-
# network that drops STUN drops the checks too: skip the 5 s of them.
|
|
408
|
-
raise PathError("no STUN answer (UDP blocked?)")
|
|
409
500
|
offer = {
|
|
410
501
|
"id": self._id,
|
|
411
502
|
"ufrag": ice.local_username,
|
|
@@ -417,6 +508,7 @@ class DirectPath:
|
|
|
417
508
|
"race": True,
|
|
418
509
|
}
|
|
419
510
|
answer = await loop.run_in_executor(None, self._offer, offer)
|
|
511
|
+
self._answered = True
|
|
420
512
|
|
|
421
513
|
# A partner lab's answer names the lab whose certificate to pin.
|
|
422
514
|
server_name = str(answer.get("server_name") or self._server_name)
|
|
@@ -633,7 +725,7 @@ def start(*, username: str, secret: str, route: dict) -> Optional[DirectPath]:
|
|
|
633
725
|
|
|
634
726
|
_path = DirectPath(
|
|
635
727
|
offer=_offer_rpc(username, secret), server_name=route["host"],
|
|
636
|
-
ca_pem=_current_profile().ca_path.read_bytes(),
|
|
728
|
+
ca_pem=_current_profile().ca_path.read_bytes(), memo=route["host"],
|
|
637
729
|
)
|
|
638
730
|
_path.start()
|
|
639
731
|
return _path
|
|
@@ -675,7 +767,7 @@ def _from_stored_login() -> Optional[DirectPath]:
|
|
|
675
767
|
return None
|
|
676
768
|
return DirectPath(
|
|
677
769
|
offer=_offer_rpc(login["username"], login["secret"]), server_name=route["host"],
|
|
678
|
-
ca_pem=profile.ca_path.read_bytes(),
|
|
770
|
+
ca_pem=profile.ca_path.read_bytes(), memo=route["host"],
|
|
679
771
|
)
|
|
680
772
|
|
|
681
773
|
|
|
@@ -698,25 +790,70 @@ def partner_of(token: Optional[str]) -> Optional[str]:
|
|
|
698
790
|
return _partner_owners.get(token) if token else None
|
|
699
791
|
|
|
700
792
|
|
|
701
|
-
def
|
|
702
|
-
"""The path
|
|
793
|
+
def _side_path(key: str, make: Callable[[], Optional[DirectPath]]) -> Optional[DirectPath]:
|
|
794
|
+
"""The path kept for ``key`` (``make`` builds it on first use; None: no
|
|
795
|
+
path), when it is ready for this call."""
|
|
703
796
|
with _lock:
|
|
704
|
-
path = _partners.get(
|
|
797
|
+
path = _partners.get(key)
|
|
705
798
|
if path is None:
|
|
706
|
-
|
|
707
|
-
if
|
|
799
|
+
path = make()
|
|
800
|
+
if path is None:
|
|
708
801
|
return None
|
|
709
|
-
|
|
710
|
-
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
|
|
711
|
-
)
|
|
802
|
+
_partners[key] = path
|
|
712
803
|
path.start()
|
|
713
|
-
if path.state
|
|
804
|
+
if path.state in ("idle", "down"):
|
|
714
805
|
path.wake(WAIT_SECONDS) # a punch takes well under a second; a 2 MB buffer will not fit the relay
|
|
715
806
|
elif path.state == "connecting":
|
|
716
807
|
path.wait() # the first call waits briefly, as a script's first capture does
|
|
717
808
|
return path if path.ready else None
|
|
718
809
|
|
|
719
810
|
|
|
811
|
+
def _partner(token: str) -> Optional[DirectPath]:
|
|
812
|
+
"""The path to the lab owning the device behind ``token``, punched on first use."""
|
|
813
|
+
def make() -> Optional[DirectPath]:
|
|
814
|
+
credentials = _credentials()
|
|
815
|
+
if credentials is None:
|
|
816
|
+
return None
|
|
817
|
+
return DirectPath(
|
|
818
|
+
offer=_offer_rpc(*credentials, token=token), server_name="", ca_pem=b"",
|
|
819
|
+
memo=_partner_owners.get(token),
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
return _side_path(token, make)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _global_offer(ref: str, deployment_id: Optional[str]) -> Callable[[dict], dict]:
|
|
826
|
+
"""The offer through Global: an ``rfg_`` token is its own credential, a ref takes the login."""
|
|
827
|
+
def send(offer: dict) -> dict:
|
|
828
|
+
from ..deployment.http import AccountBackendError
|
|
829
|
+
from . import grpc_client
|
|
830
|
+
|
|
831
|
+
backend = grpc_client._token_backend() if ref.startswith("rfg_") else grpc_client.federated_backend()
|
|
832
|
+
try:
|
|
833
|
+
answer = json.loads(backend.device_direct(ref, deployment_id, json.dumps(offer)))
|
|
834
|
+
except AccountBackendError as exc:
|
|
835
|
+
# not_found: a Global without the route; the others: a lab without it.
|
|
836
|
+
if exc.code in {"not_found", "action_unavailable", "direct_path_unavailable"}:
|
|
837
|
+
raise PathUnavailable(f"no direct path: {exc.code}") from None
|
|
838
|
+
raise PathError(str(exc)) from None
|
|
839
|
+
if not (answer.get("server_name") and answer.get("ca_pem")):
|
|
840
|
+
raise PathUnavailable("the lab's Server predates direct paths for Global users")
|
|
841
|
+
return answer
|
|
842
|
+
|
|
843
|
+
return send
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def global_path(ref: str, deployment_id: Optional[str] = None) -> Optional[DirectPath]:
|
|
847
|
+
"""The path to the lab behind a Global device ref or ``rfg_`` token,
|
|
848
|
+
signaled through Global, when it is ready for this call."""
|
|
849
|
+
if not enabled():
|
|
850
|
+
return None
|
|
851
|
+
return _side_path(ref, lambda: DirectPath(
|
|
852
|
+
offer=_global_offer(ref, deployment_id), server_name="", ca_pem=b"",
|
|
853
|
+
memo=deployment_id or ref.rsplit(":", 1)[0],
|
|
854
|
+
))
|
|
855
|
+
|
|
856
|
+
|
|
720
857
|
def current(token: Optional[str] = None) -> Optional[DirectPath]:
|
|
721
858
|
"""The path a call with ``token`` should take when it is ready, else None.
|
|
722
859
|
|
|
@@ -742,7 +879,7 @@ def current(token: Optional[str] = None) -> Optional[DirectPath]:
|
|
|
742
879
|
_path.start()
|
|
743
880
|
_path.wait()
|
|
744
881
|
path = _path
|
|
745
|
-
if path is not None and path.state
|
|
882
|
+
if path is not None and path.state in ("idle", "down"):
|
|
746
883
|
path.wake(WAIT_SECONDS)
|
|
747
884
|
return path if path is not None and path.ready else None
|
|
748
885
|
|
|
@@ -25,6 +25,7 @@ import grpc
|
|
|
25
25
|
from ..common.grpc import grpc_pb2
|
|
26
26
|
from ..common.grpc import grpc_pb2_grpc
|
|
27
27
|
from ..common.utils import *
|
|
28
|
+
from ..common.utils.process_arg import IQ16, fits_iq16, map_iq16
|
|
28
29
|
from .secure_channel import build_secure_channel
|
|
29
30
|
from . import direct_path
|
|
30
31
|
from ..deployment.direct import ConnectionProfile, DirectConnectionProfile, resolve_active_profile
|
|
@@ -171,6 +172,9 @@ def get_tcp_calls():
|
|
|
171
172
|
# GenericRPCResponse, so generated drivers do not know the difference.
|
|
172
173
|
|
|
173
174
|
_federated_backend = None
|
|
175
|
+
# A direct-path thread may ask at the same moment: two backends would each
|
|
176
|
+
# refresh the stored session, and Global revokes a replayed refresh token.
|
|
177
|
+
_federated_backend_lock = threading.Lock()
|
|
174
178
|
|
|
175
179
|
|
|
176
180
|
def bind_federated_backend(backend) -> None:
|
|
@@ -181,15 +185,16 @@ def bind_federated_backend(backend) -> None:
|
|
|
181
185
|
|
|
182
186
|
def federated_backend():
|
|
183
187
|
global _federated_backend
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
with _federated_backend_lock:
|
|
189
|
+
if _federated_backend is None:
|
|
190
|
+
from ..deployment.backend import selected_backend
|
|
191
|
+
backend = selected_backend()
|
|
192
|
+
if backend.capabilities["account_transport"] != "https-json":
|
|
193
|
+
raise RuntimeError("Federated device references require an HTTPS account home")
|
|
194
|
+
if not backend.resume():
|
|
195
|
+
raise RuntimeError("Log in to the account home first (run `remoterf`, then `login`)")
|
|
196
|
+
_federated_backend = backend
|
|
197
|
+
return _federated_backend
|
|
193
198
|
|
|
194
199
|
|
|
195
200
|
def _federated_ref(function_name, args):
|
|
@@ -207,6 +212,19 @@ def _federated_call(route, function_name, args):
|
|
|
207
212
|
"""Relay through Global: an ``rfg_`` token needs no login, a ref the bearer."""
|
|
208
213
|
from ..deployment.http import AccountBackendError
|
|
209
214
|
ref = getattr(route, "wire", route)
|
|
215
|
+
if direct_path.wants(function_name):
|
|
216
|
+
path = direct_path.global_path(ref, getattr(route, "deployment_id", None))
|
|
217
|
+
if path is not None:
|
|
218
|
+
try:
|
|
219
|
+
response = path.call(function_name, args)
|
|
220
|
+
except direct_path.PathError:
|
|
221
|
+
pass # the path is down now; this call takes the relay, once
|
|
222
|
+
else:
|
|
223
|
+
# The lab refused it unrun: the path's reservation is over, but a
|
|
224
|
+
# newer one on this device may be live, and the relay honours that.
|
|
225
|
+
refused = "a" in response.results and unmap_arg(response.results["a"]) == "the reservation has ended"
|
|
226
|
+
if not refused:
|
|
227
|
+
return response
|
|
210
228
|
request = grpc_pb2.GenericRPCRequest(function_name=function_name, args=args)
|
|
211
229
|
try:
|
|
212
230
|
if ref.startswith("rfg_"):
|
|
@@ -455,6 +473,34 @@ def rpc_client(*, function_name, args, connection=None):
|
|
|
455
473
|
# array. REMOTERF_RX_WIRE="" or "c128" opts out (servers ignore unknown).
|
|
456
474
|
# Also through a HOME, whose relay caps each response at ~768 KiB.
|
|
457
475
|
args = {**args, "wire": map_arg(wire)}
|
|
476
|
+
token = _device_token(function_name, args)
|
|
477
|
+
if function_name.endswith(":tx:CALL1") and "arg1" in args and token in _ci16_tokens:
|
|
478
|
+
args = {**args, "arg1": _tx_wire(args["arg1"])}
|
|
479
|
+
response = _dispatch(function_name, args, connection)
|
|
480
|
+
if token is not None and function_name.endswith(":rx:CALL0") and _is_ci16(response.results.get(function_name.split(":")[1])):
|
|
481
|
+
_ci16_tokens.add(token)
|
|
482
|
+
return response
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# Device tokens whose Server answered an rx in ci16: it decodes ci16 too (the
|
|
486
|
+
# same Gate I slice 3 change), so their tx may go out that way.
|
|
487
|
+
_ci16_tokens: set = set()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _is_ci16(arg) -> bool:
|
|
491
|
+
return arg is not None and arg.WhichOneof("value") == "ndarray_value" and arg.ndarray_value.dtype == IQ16
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _tx_wire(arg):
|
|
495
|
+
"""TX samples as int16 IQ, a quarter of complex128, only when that is
|
|
496
|
+
lossless (integer-valued, in range); REMOTERF_TX_WIRE="" opts out."""
|
|
497
|
+
if os.getenv("REMOTERF_TX_WIRE", "i16").strip().lower() != "i16" or arg.WhichOneof("value") != "ndarray_value":
|
|
498
|
+
return arg
|
|
499
|
+
samples = unmap_arg(arg)
|
|
500
|
+
return map_iq16(samples) if fits_iq16(samples) else arg
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _dispatch(function_name, args, connection):
|
|
458
504
|
ref = _federated_ref(function_name, args) if connection is None else None
|
|
459
505
|
if ref is not None:
|
|
460
506
|
return _finish(_federated_call(ref, function_name, args))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import re
|
|
4
4
|
import time
|
|
5
5
|
import inspect
|
|
6
|
+
import threading
|
|
6
7
|
import base64
|
|
7
8
|
from remoterf_federation_core import (
|
|
8
9
|
DEVICE_AGGREGATION_TIMEOUT_SECONDS,
|
|
@@ -98,6 +99,9 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
98
99
|
self.clock = clock
|
|
99
100
|
self.store = store or CredentialStore(self.transport.origin, ident)
|
|
100
101
|
self.credentials = None
|
|
102
|
+
# One refresh at a time: a direct-path thread shares this session, and
|
|
103
|
+
# Global revokes a session whose refresh token is presented twice.
|
|
104
|
+
self._refresh_lock = threading.RLock()
|
|
101
105
|
|
|
102
106
|
def _operation(self, name):
|
|
103
107
|
if name not in self.capabilities["supported_operations"]:
|
|
@@ -186,33 +190,35 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
186
190
|
|
|
187
191
|
def refresh(self):
|
|
188
192
|
self._operation("ACC:refresh_session")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
self.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
193
|
+
with self._refresh_lock:
|
|
194
|
+
if not self.credentials:
|
|
195
|
+
raise AccountBackendError("Log in first")
|
|
196
|
+
old = self.credentials
|
|
197
|
+
try:
|
|
198
|
+
pair = self._validate_session(
|
|
199
|
+
self.transport.request(
|
|
200
|
+
"POST",
|
|
201
|
+
self.base + "/refresh",
|
|
202
|
+
data={"refresh_token": old["refresh_token"]},
|
|
203
|
+
),
|
|
204
|
+
old["subject_id"],
|
|
205
|
+
)
|
|
206
|
+
except Exception:
|
|
207
|
+
# Ambiguous rotation must not automatically replay a spent refresh token.
|
|
208
|
+
self.credentials = None
|
|
209
|
+
self.store.clear()
|
|
210
|
+
raise
|
|
211
|
+
self.credentials = pair
|
|
212
|
+
self.store.save(pair)
|
|
213
|
+
return pair
|
|
209
214
|
|
|
210
215
|
def _access(self):
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
self.
|
|
215
|
-
|
|
216
|
+
with self._refresh_lock:
|
|
217
|
+
if not self.credentials:
|
|
218
|
+
raise AccountBackendError("Log in first")
|
|
219
|
+
if self.credentials["access_expires_at"] <= self.clock() + 5:
|
|
220
|
+
self.refresh()
|
|
221
|
+
return self.credentials["access_token"]
|
|
216
222
|
|
|
217
223
|
def _own(self, value):
|
|
218
224
|
if (
|
|
@@ -383,6 +389,25 @@ class HttpsJsonAccountBackend(DeploymentAccountBackend):
|
|
|
383
389
|
)
|
|
384
390
|
return self._device_frame(value)
|
|
385
391
|
|
|
392
|
+
def device_direct(self, ref, deployment_id, ice):
|
|
393
|
+
"""Signal a direct path to the lab behind ``ref`` (a device ref under
|
|
394
|
+
this login, or an ``rfg_`` token with no login): the ICE offer goes in,
|
|
395
|
+
the lab's signed answer comes back as Global relayed it."""
|
|
396
|
+
|
|
397
|
+
if ref.startswith("rfg_"):
|
|
398
|
+
data, auth = {"token": ref, "ice": ice}, {}
|
|
399
|
+
if deployment_id is not None:
|
|
400
|
+
data["deployment_id"] = deployment_id
|
|
401
|
+
else:
|
|
402
|
+
data, auth = {"device_id": self._ref(ref, "device_id"), "ice": ice}, {"access": self._access()}
|
|
403
|
+
value = self.transport.request(
|
|
404
|
+
"POST", self.base + "/devices/direct", data=data,
|
|
405
|
+
overall_timeout=DEVICE_WRITE_TIMEOUT_SECONDS + 5, **auth,
|
|
406
|
+
)
|
|
407
|
+
if value.get("provenance") != "destination" or type(value.get("ice")) is not str:
|
|
408
|
+
raise AccountBackendError("Invalid home device response")
|
|
409
|
+
return value["ice"]
|
|
410
|
+
|
|
386
411
|
@staticmethod
|
|
387
412
|
def _device_frame(value):
|
|
388
413
|
encoded = value.get("response_b64")
|
|
@@ -30,6 +30,9 @@ class JsonTransport:
|
|
|
30
30
|
timeout=5,
|
|
31
31
|
follow_redirects=False,
|
|
32
32
|
trust_env=False,
|
|
33
|
+
# httpx drops an idle connection after 5 s; a notebook's next call
|
|
34
|
+
# would pay a fresh TCP+TLS handshake. Stay under Caddy's 5 min.
|
|
35
|
+
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=60),
|
|
33
36
|
)
|
|
34
37
|
|
|
35
38
|
def request(
|
|
@@ -1642,6 +1642,11 @@ def _read_schema_hash(path: Path) -> str | None:
|
|
|
1642
1642
|
return None
|
|
1643
1643
|
|
|
1644
1644
|
|
|
1645
|
+
# (wire token, v1 schema hash) pairs this process already checked: a driver
|
|
1646
|
+
# re-created in a notebook makes no schema RPC for the same reservation.
|
|
1647
|
+
_verified: set = set()
|
|
1648
|
+
|
|
1649
|
+
|
|
1645
1650
|
def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
|
|
1646
1651
|
"""
|
|
1647
1652
|
Fetch the server schema hash via the reservation token and reinstall the
|
|
@@ -1657,6 +1662,17 @@ def install_driver_if_stale(*, token: str, current_hash: str) -> bool:
|
|
|
1657
1662
|
|
|
1658
1663
|
from remoterf.drivers.pluto import *
|
|
1659
1664
|
"""
|
|
1665
|
+
from ..core.grpc_client import wire_token
|
|
1666
|
+
|
|
1667
|
+
key = (wire_token(token), current_hash)
|
|
1668
|
+
if key in _verified:
|
|
1669
|
+
return False
|
|
1670
|
+
# The v1 schema first: every device publishes one, and a v1 driver (Pluto)
|
|
1671
|
+
# matches it in one RPC. The v2 probe only decides a reinstall.
|
|
1672
|
+
schema = fetch_idl(token=token, prefer_v2=False)
|
|
1673
|
+
if schema.get("schema_hash") == current_hash:
|
|
1674
|
+
_verified.add(key)
|
|
1675
|
+
return False
|
|
1660
1676
|
schema = fetch_idl(token=token, prefer_v2=True)
|
|
1661
1677
|
if schema.get("schema_hash") == current_hash:
|
|
1662
1678
|
return False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remoterf
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
|
|
5
5
|
Author-email: Ethan Ge <ethoGalaxy@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -74,6 +74,7 @@ tests/test_config_address.py
|
|
|
74
74
|
tests/test_deployment_accounts.py
|
|
75
75
|
tests/test_device_routes.py
|
|
76
76
|
tests/test_direct_path_fixed.py
|
|
77
|
+
tests/test_direct_path_global.py
|
|
77
78
|
tests/test_direct_path_partner.py
|
|
78
79
|
tests/test_direct_path_race.py
|
|
79
80
|
tests/test_dynamic_device_codegen.py
|
|
@@ -64,6 +64,7 @@ def routes(monkeypatch, tmp_path):
|
|
|
64
64
|
(config / ".env").write_text(
|
|
65
65
|
f"REMOTERF_ADDR=203.0.113.10:12321\nREMOTERF_CA_CERT={ca}\nREMOTERF_PROFILE=ucla\n")
|
|
66
66
|
monkeypatch.setattr(direct_path, "current", lambda token=None: None)
|
|
67
|
+
monkeypatch.setattr(direct_path, "global_path", lambda ref, deployment_id=None: None)
|
|
67
68
|
active = Stub()
|
|
68
69
|
monkeypatch.setattr(grpc_client, "get_active_connection", lambda: SimpleNamespace(stub=active))
|
|
69
70
|
monkeypatch.setattr(homes, "discover", _never)
|