remoteRF-server 1.1.0.dev50__py3-none-any.whl → 1.1.0.dev52__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.
- remoteRF_server/server/commands.py +1 -0
- remoteRF_server/server/direct_path.py +61 -14
- remoteRF_server/server/federation/__init__.py +2 -2
- remoteRF_server/server/federation/cli.py +43 -0
- remoteRF_server/server/federation/cluster.py +25 -5
- remoteRF_server/server/federation/devices.py +169 -24
- remoteRF_server/server/federation/home.py +84 -18
- remoteRF_server/server/federation/home_devices.py +33 -1
- remoteRF_server/server/federation/migrations.py +61 -0
- remoteRF_server/server/federation/peers.py +150 -0
- remoteRF_server/server/reservation.py +44 -0
- remoteRF_server/server/rpc_manager.py +6 -1
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/METADATA +2 -2
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/RECORD +18 -17
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/WHEEL +0 -0
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/entry_points.txt +0 -0
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/licenses/LICENSE +0 -0
- {remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/top_level.txt +0 -0
|
@@ -157,6 +157,7 @@ def _table() -> list[Command]:
|
|
|
157
157
|
Command(("cluster groups",), "Choose which of your groups a cluster shares (interactive)", _federation("groups"), "Clusters"),
|
|
158
158
|
Command(("cluster codes create",), "Codes for your users to join a cluster (interactive)", _federation("codes create"), "Clusters"),
|
|
159
159
|
Command(("cluster codes list",), "List cluster codes", _federation("codes list"), "Clusters"),
|
|
160
|
+
Command(("cluster peers",), "Partner listeners this lab dials directly: cluster peers [add <id> <host:port> --ca <ca.crt> | remove <id>]", _federation("peers"), "Clusters"),
|
|
160
161
|
Command(("cluster accept",), "", _federation("accept"), "Clusters", hidden=True),
|
|
161
162
|
Command(("global cluster",), "", _federation("cluster"), "Global", hidden=True),
|
|
162
163
|
# ---- gist ----
|
|
@@ -19,6 +19,11 @@ ICE (aioice) punches one UDP pair between a logged-in client and this Server;
|
|
|
19
19
|
QUIC (aioquic) carries device RPCs over it, one bidirectional stream per call:
|
|
20
20
|
4-byte big-endian length + GenericRPCRequest, reply likewise. Control RPCs
|
|
21
21
|
(ACC:*) stay on gRPC and are refused here. Signaling is ACC:direct_offer.
|
|
22
|
+
|
|
23
|
+
A *bound* path is the owner side of a federated reservation (docs/
|
|
24
|
+
deployment-groups.md §7.6): it carries no bearer token, every call is
|
|
25
|
+
re-authorized by the binding and pinned to its device, and a refusal closes
|
|
26
|
+
the path.
|
|
22
27
|
"""
|
|
23
28
|
|
|
24
29
|
from __future__ import annotations
|
|
@@ -113,6 +118,7 @@ STUN_SECONDS = 1.5 # a STUN server answers in milliseconds or not at all
|
|
|
113
118
|
CERT_DIR: Optional[Path] = None # set by start_server_runtime
|
|
114
119
|
|
|
115
120
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
121
|
+
_thread: Optional[threading.Thread] = None
|
|
116
122
|
_lock = threading.Lock()
|
|
117
123
|
_paths: dict[str, "_Path"] = {}
|
|
118
124
|
|
|
@@ -177,15 +183,28 @@ def peer_address(ice: Connection) -> tuple[str, int]:
|
|
|
177
183
|
return ice._nominated[1].remote_addr
|
|
178
184
|
|
|
179
185
|
|
|
180
|
-
|
|
186
|
+
class PathRefused(Exception):
|
|
187
|
+
"""A bound path's authorizer said no: the reply carries why, then the path closes."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _dispatch(request: grpc_pb2.GenericRPCRequest, peer_host: str, bound: Optional[dict] = None) -> dict:
|
|
181
191
|
"""Run one call exactly as GenericRPCServicer.Call does, device plane only."""
|
|
182
192
|
from .rpc_manager import rpc_manager
|
|
183
193
|
|
|
184
194
|
name = request.function_name
|
|
185
|
-
|
|
195
|
+
device = rpc_manager._looks_like_device_rpc(name)
|
|
196
|
+
if bound is not None:
|
|
197
|
+
if not (device or name == "IDL:get_drivers"):
|
|
198
|
+
return {"a": map_arg(f"{name} is not available on the direct path")}
|
|
199
|
+
reason = bound["authorize"](name)
|
|
200
|
+
if reason is not None:
|
|
201
|
+
raise PathRefused(reason)
|
|
202
|
+
elif not (device or name.startswith("IDL:")):
|
|
186
203
|
return {"a": map_arg(f"{name} is not available on the direct path")}
|
|
187
204
|
token = RELAYED.set(is_offsite(peer_host))
|
|
188
205
|
try:
|
|
206
|
+
if bound is not None:
|
|
207
|
+
return bound["run"](name, dict(request.args))
|
|
189
208
|
return rpc_manager.run_rpc(function_name=name, args=request.args)
|
|
190
209
|
except Exception as exc: # noqa: BLE001 - the reply carries it, as a device error would
|
|
191
210
|
return {"a": map_arg(str(exc))}
|
|
@@ -255,10 +274,11 @@ class _Protocol(QuicConnectionProtocol):
|
|
|
255
274
|
class _Path:
|
|
256
275
|
"""One client session's punched pair and the QUIC connection over it."""
|
|
257
276
|
|
|
258
|
-
def __init__(self, key: str, user: str, ice: Connection):
|
|
277
|
+
def __init__(self, key: str, user: str, ice: Connection, bound: Optional[dict] = None):
|
|
259
278
|
self.key = key
|
|
260
279
|
self.user = user
|
|
261
280
|
self.ice = ice
|
|
281
|
+
self.bound = bound
|
|
262
282
|
self.peer: Optional[tuple[str, int]] = None
|
|
263
283
|
self.calls = 0
|
|
264
284
|
self.task: Optional[asyncio.Task] = None
|
|
@@ -330,7 +350,7 @@ class _Path:
|
|
|
330
350
|
task.add_done_callback(self.streams.discard)
|
|
331
351
|
|
|
332
352
|
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
333
|
-
bad_frame = None
|
|
353
|
+
bad_frame = refused = None
|
|
334
354
|
try:
|
|
335
355
|
try:
|
|
336
356
|
request = grpc_pb2.GenericRPCRequest()
|
|
@@ -341,9 +361,13 @@ class _Path:
|
|
|
341
361
|
bad_frame = f"bad frame: {type(exc).__name__}: {exc}"
|
|
342
362
|
results = {"a": map_arg(bad_frame)}
|
|
343
363
|
else:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
364
|
+
try:
|
|
365
|
+
results = await asyncio.get_running_loop().run_in_executor(
|
|
366
|
+
None, _dispatch, request, self.peer[0], self.bound
|
|
367
|
+
)
|
|
368
|
+
except PathRefused as exc:
|
|
369
|
+
refused = str(exc)
|
|
370
|
+
results = {"a": map_arg(refused)}
|
|
347
371
|
writer.write(frame(grpc_pb2.GenericRPCResponse(results=results).SerializeToString()))
|
|
348
372
|
writer.write_eof()
|
|
349
373
|
self.calls += 1
|
|
@@ -354,6 +378,9 @@ class _Path:
|
|
|
354
378
|
# still arriving and would only pile up in this stream's buffer.
|
|
355
379
|
printf(f"Direct path dropped: {self.user} sent a {bad_frame}", Sty.GRAY)
|
|
356
380
|
await self.close()
|
|
381
|
+
if refused is not None:
|
|
382
|
+
printf(f"Direct path refused: {self.user}: {refused}", Sty.GRAY)
|
|
383
|
+
await self.close()
|
|
357
384
|
|
|
358
385
|
async def close(self) -> None:
|
|
359
386
|
if _paths.get(self.key) is self:
|
|
@@ -367,7 +394,7 @@ class _Path:
|
|
|
367
394
|
await close_ice(self.ice)
|
|
368
395
|
|
|
369
396
|
|
|
370
|
-
async def _offer(key: str, user: str, remote: dict) -> dict:
|
|
397
|
+
async def _offer(key: str, user: str, remote: dict, bound: Optional[dict] = None) -> dict:
|
|
371
398
|
old = _paths.get(key)
|
|
372
399
|
if old is not None:
|
|
373
400
|
await old.close()
|
|
@@ -386,7 +413,7 @@ async def _offer(key: str, user: str, remote: dict) -> dict:
|
|
|
386
413
|
except Exception:
|
|
387
414
|
await close_ice(ice)
|
|
388
415
|
raise
|
|
389
|
-
path = _paths[key] = _Path(key, user, ice)
|
|
416
|
+
path = _paths[key] = _Path(key, user, ice, bound)
|
|
390
417
|
path.task = asyncio.ensure_future(path.run())
|
|
391
418
|
return {
|
|
392
419
|
"ufrag": ice.local_username,
|
|
@@ -396,14 +423,15 @@ async def _offer(key: str, user: str, remote: dict) -> dict:
|
|
|
396
423
|
|
|
397
424
|
|
|
398
425
|
def _get_loop() -> asyncio.AbstractEventLoop:
|
|
399
|
-
global _loop
|
|
426
|
+
global _loop, _thread
|
|
400
427
|
with _lock:
|
|
401
428
|
if _loop is None:
|
|
402
429
|
_loop = asyncio.new_event_loop()
|
|
403
430
|
quiet_aioice_errors(_loop)
|
|
404
|
-
threading.Thread(
|
|
431
|
+
_thread = threading.Thread(
|
|
405
432
|
target=_loop.run_forever, name="remoterf-direct", daemon=True
|
|
406
|
-
)
|
|
433
|
+
)
|
|
434
|
+
_thread.start()
|
|
407
435
|
return _loop
|
|
408
436
|
|
|
409
437
|
|
|
@@ -426,11 +454,25 @@ def offer(session_key: str, user: str, ice: str) -> dict:
|
|
|
426
454
|
return {"ice": map_arg(json.dumps(answer)), "server_name": map_arg(hostname() or "")}
|
|
427
455
|
|
|
428
456
|
|
|
457
|
+
def offer_bound(session_key: str, user: str, ice: str, *, bound: dict) -> dict:
|
|
458
|
+
"""The owner side of a federated reservation's path.
|
|
459
|
+
|
|
460
|
+
``bound["authorize"](function_name)`` decides every call (None allows,
|
|
461
|
+
else the reason) and ``bound["run"](function_name, args)`` executes it
|
|
462
|
+
against the reservation's device. Returns the ICE answer; raises when no
|
|
463
|
+
path could be prepared.
|
|
464
|
+
"""
|
|
465
|
+
remote = json.loads(ice)
|
|
466
|
+
key = f"{session_key}/{str(remote.get('id', ''))[:32]}"
|
|
467
|
+
return asyncio.run_coroutine_threadsafe(_offer(key, user, remote, bound), _get_loop()).result(20)
|
|
468
|
+
|
|
469
|
+
|
|
429
470
|
def shutdown() -> None:
|
|
430
|
-
"""Close every path
|
|
431
|
-
global _loop
|
|
471
|
+
"""Close every path, stop the loop and release it (server stop, tests)."""
|
|
472
|
+
global _loop, _thread
|
|
432
473
|
with _lock:
|
|
433
474
|
loop, _loop = _loop, None
|
|
475
|
+
thread, _thread = _thread, None
|
|
434
476
|
if loop is None:
|
|
435
477
|
return
|
|
436
478
|
|
|
@@ -441,3 +483,8 @@ def shutdown() -> None:
|
|
|
441
483
|
with contextlib.suppress(Exception):
|
|
442
484
|
asyncio.run_coroutine_threadsafe(_close_all(), loop).result(10)
|
|
443
485
|
loop.call_soon_threadsafe(loop.stop)
|
|
486
|
+
if thread is not None:
|
|
487
|
+
thread.join(10)
|
|
488
|
+
if not loop.is_running():
|
|
489
|
+
with contextlib.suppress(Exception):
|
|
490
|
+
loop.close() # the selector and its wake-up pipe go with it
|
|
@@ -6,9 +6,9 @@ permission aggregation, resources, reservations, or broker authorization.
|
|
|
6
6
|
|
|
7
7
|
import remoterf_federation_core as _federation_core
|
|
8
8
|
|
|
9
|
-
if _federation_core.__version__ != "0.
|
|
9
|
+
if _federation_core.__version__ != "0.16.0":
|
|
10
10
|
raise RuntimeError(
|
|
11
|
-
"RemoteRF Server federation requires remoterf-federation-core==0.
|
|
11
|
+
"RemoteRF Server federation requires remoterf-federation-core==0.16.0; "
|
|
12
12
|
f"found {_federation_core.__version__}"
|
|
13
13
|
)
|
|
14
14
|
|
|
@@ -355,8 +355,51 @@ def cmd_cluster(argv: list[str]) -> int:
|
|
|
355
355
|
return 0
|
|
356
356
|
|
|
357
357
|
|
|
358
|
+
def cmd_peers(argv: list[str]) -> int:
|
|
359
|
+
"""`federation peers [list] | add <deployment_id> <host:port> --ca <ca.crt> [--name <tls name>]
|
|
360
|
+
| remove <deployment_id>`: the partner listeners this HOME dials directly."""
|
|
361
|
+
from ...common.utils.ansi_codes import Sty
|
|
362
|
+
from . import cluster
|
|
363
|
+
from .peers import PeerStore
|
|
364
|
+
|
|
365
|
+
store = PeerStore(cluster._db_path())
|
|
366
|
+
verb = argv[0] if argv else "list"
|
|
367
|
+
words = [a for a in argv[1:] if not a.startswith("--")]
|
|
368
|
+
flags = dict(zip([a for a in argv[1:] if a.startswith("--")], [a for a in argv[2:] if not a.startswith("--")]))
|
|
369
|
+
try:
|
|
370
|
+
if verb == "list" and not words:
|
|
371
|
+
rows = store.list()
|
|
372
|
+
if not rows:
|
|
373
|
+
_print(" No partner routes; the cluster agent records one from each lab's signed status note.",
|
|
374
|
+
Sty.GRAY)
|
|
375
|
+
for row in rows:
|
|
376
|
+
_print(f" {row['display_name']} {row['deployment_id']} {row['endpoint']} "
|
|
377
|
+
f"({row['via']}, {row['source']})")
|
|
378
|
+
return 0
|
|
379
|
+
if verb == "add" and len(words) == 2 and "--ca" in flags:
|
|
380
|
+
deployment_id, endpoint = words
|
|
381
|
+
route = {"grpc_endpoint": endpoint, "tls_server_name": flags.get("--name") or endpoint.rsplit(":", 1)[0],
|
|
382
|
+
"ca_pem": Path(flags["--ca"]).read_text("utf-8")}
|
|
383
|
+
store.upsert(deployment_id=deployment_id, display_name=flags.get("--name") or deployment_id,
|
|
384
|
+
route=route, source="admin")
|
|
385
|
+
_print(f" {deployment_id} is dialed at {endpoint} from now on.", Sty.GRAY)
|
|
386
|
+
return 0
|
|
387
|
+
if verb == "remove" and len(words) == 1:
|
|
388
|
+
gone = store.remove(words[0])
|
|
389
|
+
_print(f" {words[0]}: {'removed; the broker forwards to it again' if gone else 'no such peer'}.", Sty.GRAY)
|
|
390
|
+
return 0
|
|
391
|
+
except (ValueError, ContractError, OSError) as exc:
|
|
392
|
+
_print(f" {exc}", Sty.WARNING)
|
|
393
|
+
return 1
|
|
394
|
+
print("Usage: federation peers [list] | add <deployment_id> <host:port> --ca <ca.crt> [--name <tls name>] "
|
|
395
|
+
"| remove <deployment_id>", file=sys.stderr)
|
|
396
|
+
return 2
|
|
397
|
+
|
|
398
|
+
|
|
358
399
|
def main(argv) -> int:
|
|
359
400
|
# `cluster <verb> …` from the table; `global cluster …` is the old spelling.
|
|
401
|
+
if argv and argv[0] == "peers":
|
|
402
|
+
return cmd_peers(argv[1:])
|
|
360
403
|
if argv and argv[0] in ("cluster", "clusters"):
|
|
361
404
|
argv = argv[1:] or ["list"]
|
|
362
405
|
if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes", "info"):
|
|
@@ -753,16 +753,22 @@ def _publish_roster(name: str, cluster: dict) -> None:
|
|
|
753
753
|
|
|
754
754
|
|
|
755
755
|
def _publish_status(name: str, cluster: dict) -> None:
|
|
756
|
-
"""Any lab: post our counts for this cluster
|
|
757
|
-
|
|
758
|
-
|
|
756
|
+
"""Any lab: post our counts for this cluster, and the route the other
|
|
757
|
+
labs may dial us on directly, when either changes."""
|
|
758
|
+
from .peers import own_route
|
|
759
|
+
|
|
760
|
+
note = _sharing(name)
|
|
761
|
+
route = own_route()
|
|
762
|
+
if route is not None:
|
|
763
|
+
note["route"] = route
|
|
764
|
+
if note == cluster.get("status"):
|
|
759
765
|
return
|
|
760
766
|
key, _, me = g._signing_material()
|
|
761
767
|
owner = me if cluster["role"] == "owner" else cluster["peer"]
|
|
762
|
-
status = {"cluster_name": name, "owner": owner, "deployment_id": me, "issued_at": int(time.time()), **
|
|
768
|
+
status = {"cluster_name": name, "owner": owner, "deployment_id": me, "issued_at": int(time.time()), **note}
|
|
763
769
|
signature = base64.b64encode(key.sign(canonical_json(status).encode())).decode("ascii")
|
|
764
770
|
g.call("POST", "/v2/broker/clusters/roster", {"protocol_version": "2", "status": status, "signature": signature})
|
|
765
|
-
cluster["status"] =
|
|
771
|
+
cluster["status"] = note
|
|
766
772
|
|
|
767
773
|
|
|
768
774
|
def _take_statuses(name: str, cluster: dict, statuses: list[dict]) -> None:
|
|
@@ -777,6 +783,20 @@ def _take_statuses(name: str, cluster: dict, statuses: list[dict]) -> None:
|
|
|
777
783
|
if _roster_signed_by(peer, status, item.get("signature", "")):
|
|
778
784
|
parties[peer]["sharing"] = {"groups": status["groups"], "devices": status["devices"],
|
|
779
785
|
"detail": status.get("detail", [])}
|
|
786
|
+
if status.get("route"):
|
|
787
|
+
_pin_route(peer, parties[peer].get("display_name"), status["route"])
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _pin_route(peer: str, display_name: str | None, route: dict) -> None:
|
|
791
|
+
"""A lab's own signed route: from now on the HOME dials it directly.
|
|
792
|
+
A malformed one leaves the broker path in place."""
|
|
793
|
+
from .peers import PeerStore
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
PeerStore(_db_path()).upsert(deployment_id=peer, display_name=display_name or peer, route=route,
|
|
797
|
+
source="cluster")
|
|
798
|
+
except (ValueError, sqlite3.Error):
|
|
799
|
+
pass
|
|
780
800
|
|
|
781
801
|
|
|
782
802
|
def _roster_signed_by(owner: str, roster: dict, signature: str) -> bool:
|
|
@@ -18,8 +18,10 @@ replayed because the grant's JTI was already consumed.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import json
|
|
21
22
|
import sqlite3
|
|
22
23
|
import time
|
|
24
|
+
from contextlib import closing
|
|
23
25
|
from datetime import datetime
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
from uuid import uuid4
|
|
@@ -28,6 +30,7 @@ from remoterf_federation_core import (
|
|
|
28
30
|
DeviceProtocolError,
|
|
29
31
|
canonical_json,
|
|
30
32
|
canonical_uuid,
|
|
33
|
+
contract_allows,
|
|
31
34
|
decode_device_b64,
|
|
32
35
|
empty_device_payload,
|
|
33
36
|
encode_device_b64,
|
|
@@ -45,6 +48,91 @@ from .migrations import configure_connection, migrate_gate_h_reservations_schema
|
|
|
45
48
|
MAX_DEVICE_RPC_REQUEST_BYTES = (MAX_DEVICE_ACTION_REQUEST_BYTES // 4) * 3
|
|
46
49
|
MAX_DEVICE_RPC_RESPONSE_BYTES = (MAX_DEVICE_RESULT_BYTES // 4) * 3
|
|
47
50
|
|
|
51
|
+
# The one control-plane RPC a federated caller may run without a live
|
|
52
|
+
# reservation: it returns the driver schema the Client needs to install a
|
|
53
|
+
# driver before its reservation starts. Natively this RPC is reachable by
|
|
54
|
+
# anyone who knows a device id, so gating it on local visibility is
|
|
55
|
+
# strictly tighter, not looser.
|
|
56
|
+
IDL_FUNCTION = "IDL:get_drivers"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_bound_rpc(rpc_manager, *, device_id: int, function_name: str, args) -> dict:
|
|
60
|
+
"""One RPC against a device the caller was already authorized for.
|
|
61
|
+
|
|
62
|
+
The HOME never holds this deployment's device bearer token, and a
|
|
63
|
+
federated caller may not smuggle one in to reach another device: the call
|
|
64
|
+
is pinned to ``device_id`` whatever its arguments name.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
args = dict(args)
|
|
68
|
+
args.pop("a", None)
|
|
69
|
+
args.pop("token", None)
|
|
70
|
+
if function_name == IDL_FUNCTION:
|
|
71
|
+
args.pop("device_name", None)
|
|
72
|
+
args["device_id"] = map_arg(int(device_id))
|
|
73
|
+
return rpc_manager.run_rpc(function_name=function_name, args=args)
|
|
74
|
+
return rpc_manager.run_device_rpc_for_gid(
|
|
75
|
+
gid=int(device_id), function_name=function_name, args=args
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def path_authorizer(*, reservations, usergroups_path, claims, reservation_id, device_id, clock):
|
|
80
|
+
"""Per-call authorization for a federated direct path.
|
|
81
|
+
|
|
82
|
+
Not a bearer token: each call re-derives that the contract which admitted
|
|
83
|
+
the grant still allows DEVICE_CONTROL, the proxy is still active, and the
|
|
84
|
+
reservation is still live (``IDL:get_drivers`` needs visibility only), so
|
|
85
|
+
a cancel, a suspend or a revoke closes the path within one call.
|
|
86
|
+
Returns None to allow, else the reason the caller sees.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
from .destination import ServerFederationExecutor
|
|
90
|
+
|
|
91
|
+
issuer, subject = claims["iss"], claims["sub"]
|
|
92
|
+
contract_id, version = claims["contract_id"], claims["contract_version"]
|
|
93
|
+
|
|
94
|
+
def authorize(function_name: str):
|
|
95
|
+
account_id = None
|
|
96
|
+
if usergroups_path is not None:
|
|
97
|
+
with closing(sqlite3.connect(usergroups_path, timeout=10.0)) as conn:
|
|
98
|
+
conn.row_factory = sqlite3.Row
|
|
99
|
+
configure_connection(conn)
|
|
100
|
+
row = conn.execute(
|
|
101
|
+
"SELECT * FROM FederationContracts WHERE contract_id=? AND version=?",
|
|
102
|
+
(contract_id, version),
|
|
103
|
+
).fetchone()
|
|
104
|
+
principal = conn.execute(
|
|
105
|
+
"SELECT status, local_account_id FROM FederatedPrincipals "
|
|
106
|
+
"WHERE issuer_deployment_id=? AND remote_subject_id=?",
|
|
107
|
+
(issuer, subject),
|
|
108
|
+
).fetchone()
|
|
109
|
+
try:
|
|
110
|
+
terms = ServerFederationExecutor._terms(row) if row is not None else None
|
|
111
|
+
except Exception: # noqa: BLE001 - unreadable terms are no authority
|
|
112
|
+
terms = None
|
|
113
|
+
if terms is None or not contract_allows(
|
|
114
|
+
terms, row["state"], "DEVICE_CONTROL", now=int(clock()),
|
|
115
|
+
superseded=row["superseded_by_version"] is not None,
|
|
116
|
+
):
|
|
117
|
+
return "the agreement with your home lab is no longer active"
|
|
118
|
+
if principal is None or principal["status"] != "active":
|
|
119
|
+
return "your account at this lab is disabled"
|
|
120
|
+
account_id = int(principal["local_account_id"])
|
|
121
|
+
if function_name == IDL_FUNCTION and account_id is not None:
|
|
122
|
+
visible = {
|
|
123
|
+
int(entry["local_device_id"])
|
|
124
|
+
for entry in reservations.federated_visible_devices(local_account_id=account_id)
|
|
125
|
+
}
|
|
126
|
+
return None if int(device_id) in visible else "device is not visible"
|
|
127
|
+
live = reservations.federated_live_reservation(
|
|
128
|
+
issuer_deployment_id=issuer, remote_subject_id=subject, reservation_id=reservation_id,
|
|
129
|
+
)
|
|
130
|
+
if live is None or int(live["device_id"]) != int(device_id):
|
|
131
|
+
return "the reservation has ended"
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
return authorize
|
|
135
|
+
|
|
48
136
|
|
|
49
137
|
class _DeviceHandlerBase:
|
|
50
138
|
"""Shared destination-local resolution for every device action."""
|
|
@@ -60,8 +148,10 @@ class _DeviceHandlerBase:
|
|
|
60
148
|
clock=None,
|
|
61
149
|
reservation_handler=None,
|
|
62
150
|
rpc_manager=None,
|
|
151
|
+
usergroups_path=None,
|
|
63
152
|
):
|
|
64
153
|
self.reservations_path = Path(reservations_path)
|
|
154
|
+
self.usergroups_path = Path(usergroups_path) if usergroups_path else None
|
|
65
155
|
self.local_deployment_id = canonical_uuid(local_deployment_id)
|
|
66
156
|
self.display_name = str(display_name)
|
|
67
157
|
self.clock = clock or (lambda: int(time.time()))
|
|
@@ -294,12 +384,7 @@ class ServerDeviceRpcHandler(_DeviceHandlerBase):
|
|
|
294
384
|
|
|
295
385
|
action = "device_rpc"
|
|
296
386
|
|
|
297
|
-
|
|
298
|
-
# reservation: it returns the driver schema the Client needs to install a
|
|
299
|
-
# driver before its reservation starts. Natively this RPC is reachable by
|
|
300
|
-
# anyone who knows a device id, so gating it on local visibility is
|
|
301
|
-
# strictly tighter, not looser.
|
|
302
|
-
IDL_FUNCTION = "IDL:get_drivers"
|
|
387
|
+
IDL_FUNCTION = IDL_FUNCTION
|
|
303
388
|
|
|
304
389
|
@staticmethod
|
|
305
390
|
def _parse_frame(payload):
|
|
@@ -373,22 +458,10 @@ class ServerDeviceRpcHandler(_DeviceHandlerBase):
|
|
|
373
458
|
device_id = int(payload["local_device_id"])
|
|
374
459
|
function_name, args = self._parse_frame(payload)
|
|
375
460
|
|
|
376
|
-
# The HOME never holds this deployment's device bearer token, and a
|
|
377
|
-
# federated caller may not smuggle one in to reach another device.
|
|
378
|
-
args.pop("a", None)
|
|
379
|
-
args.pop("token", None)
|
|
380
|
-
|
|
381
461
|
try:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
args.pop("device_name", None)
|
|
386
|
-
args["device_id"] = map_arg(int(device_id))
|
|
387
|
-
results = self.rpc.run_rpc(function_name=function_name, args=args)
|
|
388
|
-
else:
|
|
389
|
-
results = self.rpc.run_device_rpc_for_gid(
|
|
390
|
-
gid=device_id, function_name=function_name, args=args
|
|
391
|
-
)
|
|
462
|
+
results = run_bound_rpc(
|
|
463
|
+
self.rpc, device_id=device_id, function_name=function_name, args=args
|
|
464
|
+
)
|
|
392
465
|
except Exception:
|
|
393
466
|
raise FederationBusinessError("device_unavailable") from None
|
|
394
467
|
|
|
@@ -412,6 +485,74 @@ class ServerDeviceRpcHandler(_DeviceHandlerBase):
|
|
|
412
485
|
}
|
|
413
486
|
|
|
414
487
|
|
|
488
|
+
class ServerDirectOpenHandler(_DeviceHandlerBase):
|
|
489
|
+
"""Answer a client's ICE offer for one live federated reservation: Gate I
|
|
490
|
+
across deployments (docs/deployment-groups.md §7.6).
|
|
491
|
+
|
|
492
|
+
Authorization (an active proxy, a reservation of its own that is live
|
|
493
|
+
right now) is decided and the grant consumed inside the executor's
|
|
494
|
+
transaction; the path itself is opened in finalize(), after commit. Every
|
|
495
|
+
call on that path is then re-authorized by ``path_authorizer``.
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
action = "direct_open"
|
|
499
|
+
|
|
500
|
+
def _live(self, action):
|
|
501
|
+
claims, payload = action.claims, action.request.value["payload"]
|
|
502
|
+
live = self.reservations.federated_live_reservation(
|
|
503
|
+
issuer_deployment_id=claims["iss"],
|
|
504
|
+
remote_subject_id=claims["sub"],
|
|
505
|
+
reservation_id=int(payload["reservation_id"]),
|
|
506
|
+
)
|
|
507
|
+
if live is None:
|
|
508
|
+
raise FederationBusinessError("no_active_reservation")
|
|
509
|
+
return live
|
|
510
|
+
|
|
511
|
+
def __call__(self, tx, action):
|
|
512
|
+
now = int(self.clock())
|
|
513
|
+
principal_id, _account_id = self._principal(tx, action.claims)
|
|
514
|
+
live = self._live(action)
|
|
515
|
+
reservation_id = int(action.request.value["payload"]["reservation_id"])
|
|
516
|
+
self._receipt(
|
|
517
|
+
tx, action, code="authorized", principal_id=principal_id,
|
|
518
|
+
device_id=live["device_id"], reservation_id=reservation_id,
|
|
519
|
+
)
|
|
520
|
+
self._audit(
|
|
521
|
+
tx, action, code="authorized", principal_id=principal_id,
|
|
522
|
+
metadata={"local_device_id": str(live["device_id"]), "reservation_id": str(reservation_id)},
|
|
523
|
+
)
|
|
524
|
+
return {
|
|
525
|
+
**self._ok(action, empty_device_payload("direct_open"), now=now),
|
|
526
|
+
"status": "error",
|
|
527
|
+
"error_code": FINALIZE_PENDING,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def finalize(self, action, result):
|
|
531
|
+
from .. import direct_path
|
|
532
|
+
|
|
533
|
+
claims, payload = action.claims, action.request.value["payload"]
|
|
534
|
+
live = self._live(action)
|
|
535
|
+
reservation_id = int(payload["reservation_id"])
|
|
536
|
+
device_id = int(live["device_id"])
|
|
537
|
+
rpc = self.rpc
|
|
538
|
+
authorize = path_authorizer(
|
|
539
|
+
reservations=self.reservations, usergroups_path=self.usergroups_path, claims=claims,
|
|
540
|
+
reservation_id=reservation_id, device_id=device_id, clock=self.clock,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def run(function_name, args):
|
|
544
|
+
return run_bound_rpc(rpc, device_id=device_id, function_name=function_name, args=args)
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
answer = direct_path.offer_bound(
|
|
548
|
+
f"federated/{claims['iss']}/{claims['sub']}/{reservation_id}",
|
|
549
|
+
live["username"], payload["ice"], bound={"run": run, "authorize": authorize},
|
|
550
|
+
)
|
|
551
|
+
except Exception:
|
|
552
|
+
raise FederationBusinessError("direct_path_unavailable") from None
|
|
553
|
+
return {**result, "status": "ok", "error_code": None, "payload": {"ice": json.dumps(answer)}}
|
|
554
|
+
|
|
555
|
+
|
|
415
556
|
def build_device_handlers(
|
|
416
557
|
*,
|
|
417
558
|
reservations_path,
|
|
@@ -420,6 +561,7 @@ def build_device_handlers(
|
|
|
420
561
|
clock=None,
|
|
421
562
|
reservation_handler=None,
|
|
422
563
|
rpc_manager=None,
|
|
564
|
+
usergroups_path=None,
|
|
423
565
|
) -> dict:
|
|
424
566
|
common = {
|
|
425
567
|
"reservations_path": reservations_path,
|
|
@@ -428,6 +570,7 @@ def build_device_handlers(
|
|
|
428
570
|
"clock": clock,
|
|
429
571
|
"reservation_handler": reservation_handler,
|
|
430
572
|
"rpc_manager": rpc_manager,
|
|
573
|
+
"usergroups_path": usergroups_path,
|
|
431
574
|
}
|
|
432
575
|
return {
|
|
433
576
|
"device_list": ServerDeviceListHandler(**common),
|
|
@@ -435,6 +578,7 @@ def build_device_handlers(
|
|
|
435
578
|
"device_reserve": ServerDeviceReserveHandler(**common),
|
|
436
579
|
"device_cancel": ServerDeviceCancelHandler(**common),
|
|
437
580
|
"device_rpc": ServerDeviceRpcHandler(**common),
|
|
581
|
+
"direct_open": ServerDirectOpenHandler(**common),
|
|
438
582
|
}
|
|
439
583
|
|
|
440
584
|
|
|
@@ -451,19 +595,19 @@ def build_server_gate_h_executor(
|
|
|
451
595
|
reservation_handler=None,
|
|
452
596
|
rpc_manager=None,
|
|
453
597
|
):
|
|
454
|
-
"""Gate G registry plus the
|
|
598
|
+
"""Gate G registry plus the device actions.
|
|
455
599
|
|
|
456
600
|
The Gate H usergroups migration runs first because it widens the grant
|
|
457
601
|
replay table's action CHECK; without it a device grant's JTI could not be
|
|
458
602
|
consumed and every device action would fail closed as an internal error.
|
|
459
603
|
"""
|
|
460
604
|
|
|
461
|
-
from .migrations import
|
|
605
|
+
from .migrations import migrate_peers_usergroups_schema
|
|
462
606
|
from .permissions import build_server_gate_g_executor
|
|
463
607
|
|
|
464
608
|
with sqlite3.connect(Path(usergroups_path), timeout=10.0) as conn:
|
|
465
609
|
configure_connection(conn)
|
|
466
|
-
|
|
610
|
+
migrate_peers_usergroups_schema(conn)
|
|
467
611
|
executor = build_server_gate_g_executor(
|
|
468
612
|
usergroups_path=usergroups_path,
|
|
469
613
|
reservations_path=reservations_path,
|
|
@@ -481,6 +625,7 @@ def build_server_gate_h_executor(
|
|
|
481
625
|
clock=clock,
|
|
482
626
|
reservation_handler=reservation_handler,
|
|
483
627
|
rpc_manager=rpc_manager,
|
|
628
|
+
usergroups_path=usergroups_path,
|
|
484
629
|
).items():
|
|
485
630
|
executor.registry.register(action, handler)
|
|
486
631
|
return executor
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from typing import Mapping
|
|
13
13
|
from uuid import uuid4
|
|
14
14
|
|
|
15
|
+
import grpc
|
|
15
16
|
import jwt
|
|
16
17
|
import requests
|
|
17
18
|
from cryptography.hazmat.primitives import serialization
|
|
@@ -24,6 +25,7 @@ from remoterf_federation_core import (
|
|
|
24
25
|
BROKER_REQUEST_TYP,
|
|
25
26
|
DEFAULT_ACTION_RETRY_POLICIES,
|
|
26
27
|
FEDERATION_GRANT_TYP,
|
|
28
|
+
MAX_DEVICE_FORWARD_REQUEST_BYTES,
|
|
27
29
|
MAX_DEVICE_FORWARD_RESPONSE_BYTES,
|
|
28
30
|
MAX_PROTOCOL_INTEGER,
|
|
29
31
|
PERMISSIONS_CONNECT_TIMEOUT_SECONDS,
|
|
@@ -53,6 +55,7 @@ from remoterf_federation_core import (
|
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
from .migrations import configure_connection, migrate_gate_e_usergroups_schema
|
|
58
|
+
from .peers import PeerStore
|
|
56
59
|
|
|
57
60
|
FORWARD_PATH = "/v2/broker/federation/forward"
|
|
58
61
|
MAX_FORWARD_RESPONSE_BYTES = 32768
|
|
@@ -376,7 +379,13 @@ class ServerHomeGrantIssuer:
|
|
|
376
379
|
|
|
377
380
|
|
|
378
381
|
class HomeFederationOutbound:
|
|
379
|
-
"""Fresh grant
|
|
382
|
+
"""Fresh grant per attempt, with one bounded retry.
|
|
383
|
+
|
|
384
|
+
A destination with a FederationPeers row is dialed directly: its own
|
|
385
|
+
FederationV2 listener, TLS pinned to the CA in the row. Any other
|
|
386
|
+
destination goes through the broker's forward path, as before. Grant,
|
|
387
|
+
envelope and result verification are the same either way.
|
|
388
|
+
"""
|
|
380
389
|
|
|
381
390
|
def __init__(
|
|
382
391
|
self,
|
|
@@ -404,6 +413,7 @@ class HomeFederationOutbound:
|
|
|
404
413
|
self.read_timeout = self.timeout
|
|
405
414
|
self.overall_timeout = self.timeout * 2
|
|
406
415
|
self.sender = sender
|
|
416
|
+
self.peers = PeerStore(issuer.usergroups_path)
|
|
407
417
|
self._sender_accepts_deadline = bool(
|
|
408
418
|
sender is not None
|
|
409
419
|
and len(inspect.signature(sender).parameters) >= 3
|
|
@@ -480,6 +490,50 @@ class HomeFederationOutbound:
|
|
|
480
490
|
"invalid_transport_response", retryable=False
|
|
481
491
|
) from None
|
|
482
492
|
|
|
493
|
+
def peer(self, deployment_id: str) -> dict | None:
|
|
494
|
+
"""The FederationPeers row for a destination, or None (broker path)."""
|
|
495
|
+
return self.peers.get(deployment_id)
|
|
496
|
+
|
|
497
|
+
def _dial(self, route: dict, grant: str, request_json: str, deadline: float) -> dict:
|
|
498
|
+
"""FederationV2.Execute on the peer's own listener, pinned to its CA."""
|
|
499
|
+
from ...common.grpc import federation_pb2 as pb
|
|
500
|
+
from ...common.grpc import federation_pb2_grpc as rpc
|
|
501
|
+
|
|
502
|
+
remaining = deadline - time.monotonic()
|
|
503
|
+
if remaining <= 0:
|
|
504
|
+
raise TimeoutError()
|
|
505
|
+
credentials = grpc.ssl_channel_credentials(
|
|
506
|
+
root_certificates=route["ca_pem"].encode("utf-8")
|
|
507
|
+
)
|
|
508
|
+
options = (
|
|
509
|
+
("grpc.ssl_target_name_override", route["tls_server_name"]),
|
|
510
|
+
("grpc.default_authority", route["tls_server_name"]),
|
|
511
|
+
("grpc.max_send_message_length", MAX_DEVICE_FORWARD_REQUEST_BYTES),
|
|
512
|
+
("grpc.max_receive_message_length", MAX_DEVICE_FORWARD_RESPONSE_BYTES),
|
|
513
|
+
)
|
|
514
|
+
# ponytail: one channel per call; cache per peer if the handshake
|
|
515
|
+
# ever shows up in a fan-out budget.
|
|
516
|
+
with grpc.secure_channel(route["endpoint"], credentials, options=options) as channel:
|
|
517
|
+
response = rpc.FederationV2Stub(channel).Execute(
|
|
518
|
+
pb.FederationRequest(
|
|
519
|
+
protocol_version="2", grant=grant, request_json=request_json
|
|
520
|
+
),
|
|
521
|
+
timeout=remaining,
|
|
522
|
+
)
|
|
523
|
+
return {
|
|
524
|
+
"protocol_version": response.protocol_version,
|
|
525
|
+
"signed_result": response.signed_result or None,
|
|
526
|
+
"error": (
|
|
527
|
+
{
|
|
528
|
+
"code": response.error.code,
|
|
529
|
+
"message": response.error.message,
|
|
530
|
+
"retryable": response.error.retryable,
|
|
531
|
+
}
|
|
532
|
+
if response.HasField("error")
|
|
533
|
+
else None
|
|
534
|
+
),
|
|
535
|
+
}
|
|
536
|
+
|
|
483
537
|
def _broker_token(self, body: bytes) -> str:
|
|
484
538
|
now = int(self.issuer.clock())
|
|
485
539
|
claims = build_broker_request_claims(
|
|
@@ -531,6 +585,13 @@ class HomeFederationOutbound:
|
|
|
531
585
|
)
|
|
532
586
|
own_deadline = time.monotonic() + budget
|
|
533
587
|
deadline = own_deadline if deadline is None else min(float(deadline), own_deadline)
|
|
588
|
+
route = None if self.sender is not None else self.peer(destination_deployment_id)
|
|
589
|
+
logger.info(
|
|
590
|
+
"federation_transport destination=%s action=%s via=%s",
|
|
591
|
+
canonical_uuid(destination_deployment_id),
|
|
592
|
+
action,
|
|
593
|
+
"in-process" if self.sender is not None else route["via"] if route else "broker",
|
|
594
|
+
)
|
|
534
595
|
for attempt in range(attempts):
|
|
535
596
|
if time.monotonic() >= deadline:
|
|
536
597
|
raise TransportFailure("route_unavailable", retryable=False)
|
|
@@ -544,25 +605,30 @@ class HomeFederationOutbound:
|
|
|
544
605
|
code_name=code_name,
|
|
545
606
|
device_payload=device_payload,
|
|
546
607
|
)
|
|
547
|
-
body = canonical_json(
|
|
548
|
-
{
|
|
549
|
-
"protocol_version": "2",
|
|
550
|
-
"grant": issued.grant,
|
|
551
|
-
"request_json": issued.request.json,
|
|
552
|
-
}
|
|
553
|
-
).encode("utf-8")
|
|
554
|
-
outer = self._broker_token(body)
|
|
555
608
|
try:
|
|
556
|
-
|
|
557
|
-
self.
|
|
558
|
-
|
|
559
|
-
else self.sender(body, outer)
|
|
560
|
-
if self.sender is not None
|
|
561
|
-
else self._send(
|
|
562
|
-
body, outer, deadline, max_forward_response_bytes(action)
|
|
609
|
+
if route is not None:
|
|
610
|
+
received = self._dial(
|
|
611
|
+
route, issued.grant, issued.request.json, deadline
|
|
563
612
|
)
|
|
564
|
-
|
|
565
|
-
|
|
613
|
+
else:
|
|
614
|
+
body = canonical_json(
|
|
615
|
+
{
|
|
616
|
+
"protocol_version": "2",
|
|
617
|
+
"grant": issued.grant,
|
|
618
|
+
"request_json": issued.request.json,
|
|
619
|
+
}
|
|
620
|
+
).encode("utf-8")
|
|
621
|
+
outer = self._broker_token(body)
|
|
622
|
+
received = (
|
|
623
|
+
self.sender(body, outer, deadline)
|
|
624
|
+
if self._sender_accepts_deadline
|
|
625
|
+
else self.sender(body, outer)
|
|
626
|
+
if self.sender is not None
|
|
627
|
+
else self._send(
|
|
628
|
+
body, outer, deadline, max_forward_response_bytes(action)
|
|
629
|
+
)
|
|
630
|
+
)
|
|
631
|
+
except (requests.RequestException, grpc.RpcError, TimeoutError, ConnectionError, OSError):
|
|
566
632
|
if attempt + 1 < attempts:
|
|
567
633
|
continue
|
|
568
634
|
raise TransportFailure("route_unavailable", retryable=False) from None
|
|
@@ -382,7 +382,7 @@ class ServerHomeDevices:
|
|
|
382
382
|
with self._connect() as conn:
|
|
383
383
|
rows = conn.execute(
|
|
384
384
|
"SELECT r.res_id, r.salt, r.hash, a.alias_id, a.owner_deployment_id, "
|
|
385
|
-
"a.remote_device_id, h.local_account_id "
|
|
385
|
+
"a.remote_device_id, h.local_account_id, h.remote_reservation_id "
|
|
386
386
|
"FROM HomeFederatedReservations h "
|
|
387
387
|
"JOIN Reservations r ON r.res_id=h.res_id "
|
|
388
388
|
"JOIN FederatedDeviceAliases a ON a.alias_id=r.device_id"
|
|
@@ -402,6 +402,38 @@ class ServerHomeDevices:
|
|
|
402
402
|
).fetchone()
|
|
403
403
|
return int(row[0]) if row else None
|
|
404
404
|
|
|
405
|
+
def direct_offer(self, *, local_account_id: int, token: str, ice: str) -> dict:
|
|
406
|
+
"""Have the owner answer this client's ICE offer for the reservation
|
|
407
|
+
behind a HOME-minted token (docs/deployment-groups.md §7.6).
|
|
408
|
+
|
|
409
|
+
Returns the answer plus what the client needs to pin the owner's QUIC
|
|
410
|
+
handshake: the owner's TLS name and CA from its FederationPeers row.
|
|
411
|
+
Without that row there is no direct route, and the client simply
|
|
412
|
+
keeps driving the device through this HOME.
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
route = self.resolve_token(token)
|
|
416
|
+
if route is None or int(route["local_account_id"]) != int(local_account_id):
|
|
417
|
+
raise HomeDeviceError("That token does not name a partner device of yours.")
|
|
418
|
+
owner = route["owner_deployment_id"]
|
|
419
|
+
peer = self.outbound.peer(owner)
|
|
420
|
+
if peer is None:
|
|
421
|
+
raise HomeDeviceError("No direct route to the partner lab is known yet.")
|
|
422
|
+
candidate = self._destination(local_account_id, owner, "DEVICE_CONTROL")
|
|
423
|
+
try:
|
|
424
|
+
result = self._forward(
|
|
425
|
+
local_account_id, candidate, "direct_open",
|
|
426
|
+
reservation_id=str(route["remote_reservation_id"]), ice=ice,
|
|
427
|
+
)
|
|
428
|
+
except Exception as exc: # noqa: BLE001
|
|
429
|
+
raise HomeDeviceError(_failure_text(exc)) from None
|
|
430
|
+
return {
|
|
431
|
+
"ice": result["payload"]["ice"],
|
|
432
|
+
"server_name": peer["tls_server_name"],
|
|
433
|
+
"ca_pem": peer["ca_pem"],
|
|
434
|
+
"owner": owner,
|
|
435
|
+
}
|
|
436
|
+
|
|
405
437
|
def rpc(self, *, local_account_id: int, alias, function_name: str, args: dict) -> dict:
|
|
406
438
|
"""Run one device RPC on the owner; returns the owner's results map."""
|
|
407
439
|
|
|
@@ -44,6 +44,12 @@ GATE_H_JTI_ACTIONS = (
|
|
|
44
44
|
"device_cancel",
|
|
45
45
|
"device_rpc",
|
|
46
46
|
)
|
|
47
|
+
# Schema 8: where each partner's FederationV2 listener is and the CA pinned
|
|
48
|
+
# for it (the HOME dials it directly instead of the broker), and the
|
|
49
|
+
# federated direct path's `direct_open` grants join the replay table.
|
|
50
|
+
PEERS_MIGRATION_VERSION = 8
|
|
51
|
+
PEERS_MIGRATION_NAME = "federation_peers_direct_open_v1"
|
|
52
|
+
PEERS_JTI_ACTIONS = GATE_H_JTI_ACTIONS + ("direct_open",)
|
|
47
53
|
FEDERATED_PASSWORD_SALT = "!remoterf-federated-salt-v2!"
|
|
48
54
|
FEDERATED_PASSWORD_HASH = "!remoterf-federated-no-password-v2!"
|
|
49
55
|
|
|
@@ -423,6 +429,28 @@ _GATE_H_MIGRATION_CHECKSUM = hashlib.sha256(
|
|
|
423
429
|
|
|
424
430
|
# Written into reservations.db so a federated reservation row and the record
|
|
425
431
|
# authorizing it commit together in one transaction.
|
|
432
|
+
# The Gate H rebuild, once more, with the direct path's action admitted.
|
|
433
|
+
_PEERS_JTI_REBUILD = _GATE_H_JTI_REBUILD.replace(
|
|
434
|
+
_GATE_H_JTI_ACTION_CHECK, ",".join(f"'{name}'" for name in PEERS_JTI_ACTIONS)
|
|
435
|
+
).replace("FederationGrantJTIsGateH", "FederationGrantJTIsPeers")
|
|
436
|
+
|
|
437
|
+
_PEERS_DDL = """
|
|
438
|
+
CREATE TABLE IF NOT EXISTS FederationPeers (
|
|
439
|
+
deployment_id TEXT PRIMARY KEY CHECK(length(deployment_id)=36),
|
|
440
|
+
display_name TEXT NOT NULL,
|
|
441
|
+
endpoint TEXT NOT NULL,
|
|
442
|
+
tls_server_name TEXT NOT NULL,
|
|
443
|
+
ca_pem TEXT NOT NULL,
|
|
444
|
+
via TEXT NOT NULL CHECK(via IN ('direct','relay')),
|
|
445
|
+
source TEXT NOT NULL CHECK(source IN ('admin','cluster')),
|
|
446
|
+
updated_at INTEGER NOT NULL
|
|
447
|
+
);
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
_PEERS_MIGRATION_CHECKSUM = hashlib.sha256(
|
|
451
|
+
(_PEERS_JTI_REBUILD + _PEERS_DDL).encode("utf-8")
|
|
452
|
+
).hexdigest()
|
|
453
|
+
|
|
426
454
|
_GATE_H_RESERVATIONS_DDL = """
|
|
427
455
|
CREATE TABLE IF NOT EXISTS FederatedReservations (
|
|
428
456
|
res_id INTEGER PRIMARY KEY,
|
|
@@ -840,6 +868,39 @@ def migrate_gate_h_reservations_schema(conn: sqlite3.Connection) -> None:
|
|
|
840
868
|
conn.executescript(_GATE_H_RESERVATIONS_DDL)
|
|
841
869
|
|
|
842
870
|
|
|
871
|
+
def _peers_jti_needs_rebuild(conn: sqlite3.Connection) -> bool:
|
|
872
|
+
row = conn.execute(
|
|
873
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
|
874
|
+
("FederationGrantJTIs",),
|
|
875
|
+
).fetchone()
|
|
876
|
+
return row is not None and "'direct_open'" not in (row[0] or "")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def migrate_peers_usergroups_schema(conn: sqlite3.Connection) -> None:
|
|
880
|
+
"""Schema 8: FederationPeers, and `direct_open` in the replay table."""
|
|
881
|
+
|
|
882
|
+
migrate_gate_h_usergroups_schema(conn)
|
|
883
|
+
row = conn.execute(
|
|
884
|
+
"SELECT checksum FROM SchemaMigrations WHERE name=?",
|
|
885
|
+
(PEERS_MIGRATION_NAME,),
|
|
886
|
+
).fetchone()
|
|
887
|
+
if row is not None and row[0] != _PEERS_MIGRATION_CHECKSUM:
|
|
888
|
+
raise SchemaNotReady("Peers migration checksum mismatch")
|
|
889
|
+
if _peers_jti_needs_rebuild(conn):
|
|
890
|
+
conn.executescript(_PEERS_JTI_REBUILD)
|
|
891
|
+
conn.executescript(_PEERS_DDL)
|
|
892
|
+
conn.execute(
|
|
893
|
+
"INSERT OR IGNORE INTO SchemaMigrations "
|
|
894
|
+
"(version,name,checksum,applied_at) VALUES (?,?,?,?)",
|
|
895
|
+
(
|
|
896
|
+
PEERS_MIGRATION_VERSION,
|
|
897
|
+
PEERS_MIGRATION_NAME,
|
|
898
|
+
_PEERS_MIGRATION_CHECKSUM,
|
|
899
|
+
int(time.time()),
|
|
900
|
+
),
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
843
904
|
def migrate_home_devices_reservations_schema(conn: sqlite3.Connection) -> None:
|
|
844
905
|
conn.executescript(_HOME_DEVICES_RESERVATIONS_DDL)
|
|
845
906
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Copyright (C) 2026 RemoteRF
|
|
2
|
+
#
|
|
3
|
+
# This program is free software: you can redistribute it and/or modify
|
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
# (at your option) any later version.
|
|
7
|
+
#
|
|
8
|
+
# This program is distributed in the hope that it will be useful,
|
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
# GNU General Public License for more details.
|
|
12
|
+
#
|
|
13
|
+
# You should have received a copy of the GNU General Public License
|
|
14
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
15
|
+
|
|
16
|
+
"""FederationPeers: where a partner's FederationV2 listener is and the CA
|
|
17
|
+
pinned for it, so the HOME dials the partner itself instead of asking the
|
|
18
|
+
broker to forward.
|
|
19
|
+
|
|
20
|
+
A row arrives one of two ways: an admin adds it (`serverrf federation peers
|
|
21
|
+
add`), or the cluster agent takes it from the partner's own signed status
|
|
22
|
+
note, verified with the key pinned at join -- so Global relays the route but
|
|
23
|
+
can never substitute one. An admin row is never overwritten by the agent.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sqlite3
|
|
31
|
+
import time
|
|
32
|
+
from contextlib import closing
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from cryptography import x509
|
|
36
|
+
from remoterf_federation_core import canonical_uuid
|
|
37
|
+
|
|
38
|
+
from .migrations import configure_connection, migrate_peers_usergroups_schema
|
|
39
|
+
|
|
40
|
+
MAX_CA_PEM_BYTES = 16384
|
|
41
|
+
_ENDPOINT = re.compile(r"^(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([1-9][0-9]{0,4})$")
|
|
42
|
+
_HOSTNAME = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
|
|
43
|
+
ROUTE_FIELDS = frozenset({"grpc_endpoint", "tls_server_name", "ca_pem"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_route(route: object) -> dict:
|
|
47
|
+
"""The route a lab advertises for itself, as the store keeps it."""
|
|
48
|
+
if type(route) is not dict or set(route) != ROUTE_FIELDS:
|
|
49
|
+
raise ValueError("route has the wrong fields")
|
|
50
|
+
endpoint, name, ca_pem = route["grpc_endpoint"], route["tls_server_name"], route["ca_pem"]
|
|
51
|
+
if type(endpoint) is not str or _ENDPOINT.match(endpoint) is None:
|
|
52
|
+
raise ValueError("route endpoint must be host:port")
|
|
53
|
+
if type(name) is not str or _HOSTNAME.match(name) is None:
|
|
54
|
+
raise ValueError("route tls_server_name must be a hostname")
|
|
55
|
+
if type(ca_pem) is not str or not 0 < len(ca_pem) <= MAX_CA_PEM_BYTES:
|
|
56
|
+
raise ValueError("route ca_pem is missing or too large")
|
|
57
|
+
try:
|
|
58
|
+
x509.load_pem_x509_certificate(ca_pem.encode("utf-8"))
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise ValueError("route ca_pem is not a PEM certificate") from None
|
|
61
|
+
return {"grpc_endpoint": endpoint, "tls_server_name": name, "ca_pem": ca_pem}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def via(endpoint: str) -> str:
|
|
65
|
+
"""'relay' for a Global relay name, 'direct' for anything the admin gave."""
|
|
66
|
+
from .. import global_onboarding
|
|
67
|
+
|
|
68
|
+
host = endpoint.rsplit(":", 1)[0]
|
|
69
|
+
return "relay" if host.endswith("." + global_onboarding.domain()) else "direct"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PeerStore:
|
|
73
|
+
def __init__(self, usergroups_path):
|
|
74
|
+
self.path = Path(usergroups_path)
|
|
75
|
+
self._ready = False
|
|
76
|
+
|
|
77
|
+
def _connect(self) -> sqlite3.Connection:
|
|
78
|
+
conn = sqlite3.connect(self.path, timeout=10.0)
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
configure_connection(conn)
|
|
81
|
+
if not self._ready:
|
|
82
|
+
migrate_peers_usergroups_schema(conn)
|
|
83
|
+
conn.commit() # the migration's writes must not leave a transaction open
|
|
84
|
+
self._ready = True
|
|
85
|
+
return conn
|
|
86
|
+
|
|
87
|
+
def get(self, deployment_id: str) -> dict | None:
|
|
88
|
+
with closing(self._connect()) as conn:
|
|
89
|
+
row = conn.execute(
|
|
90
|
+
"SELECT * FROM FederationPeers WHERE deployment_id=?", (canonical_uuid(deployment_id),)
|
|
91
|
+
).fetchone()
|
|
92
|
+
return dict(row) if row is not None else None
|
|
93
|
+
|
|
94
|
+
def list(self) -> list[dict]:
|
|
95
|
+
with closing(self._connect()) as conn:
|
|
96
|
+
return [dict(row) for row in conn.execute("SELECT * FROM FederationPeers ORDER BY display_name")]
|
|
97
|
+
|
|
98
|
+
def upsert(self, *, deployment_id: str, display_name: str, route: dict, source: str) -> bool:
|
|
99
|
+
"""Record a route; a cluster note never replaces what an admin set."""
|
|
100
|
+
if source not in ("admin", "cluster"):
|
|
101
|
+
raise ValueError("source must be admin or cluster")
|
|
102
|
+
clean = validate_route(route)
|
|
103
|
+
deployment_id = canonical_uuid(deployment_id)
|
|
104
|
+
with closing(self._connect()) as conn, conn:
|
|
105
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
106
|
+
row = conn.execute(
|
|
107
|
+
"SELECT source FROM FederationPeers WHERE deployment_id=?", (deployment_id,)
|
|
108
|
+
).fetchone()
|
|
109
|
+
if row is not None and row["source"] == "admin" and source == "cluster":
|
|
110
|
+
return False
|
|
111
|
+
conn.execute(
|
|
112
|
+
"INSERT INTO FederationPeers "
|
|
113
|
+
"(deployment_id,display_name,endpoint,tls_server_name,ca_pem,via,source,updated_at) "
|
|
114
|
+
"VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(deployment_id) DO UPDATE SET "
|
|
115
|
+
"display_name=excluded.display_name, endpoint=excluded.endpoint, "
|
|
116
|
+
"tls_server_name=excluded.tls_server_name, ca_pem=excluded.ca_pem, "
|
|
117
|
+
"via=excluded.via, source=excluded.source, updated_at=excluded.updated_at",
|
|
118
|
+
(
|
|
119
|
+
deployment_id, str(display_name or deployment_id)[:256], clean["grpc_endpoint"],
|
|
120
|
+
clean["tls_server_name"], clean["ca_pem"], via(clean["grpc_endpoint"]), source,
|
|
121
|
+
int(time.time()),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def remove(self, deployment_id: str) -> bool:
|
|
127
|
+
with closing(self._connect()) as conn, conn:
|
|
128
|
+
return conn.execute(
|
|
129
|
+
"DELETE FROM FederationPeers WHERE deployment_id=?", (canonical_uuid(deployment_id),)
|
|
130
|
+
).rowcount > 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def own_route() -> dict | None:
|
|
134
|
+
"""This lab's relay route, for its status note: the name clients dial,
|
|
135
|
+
the relay's gRPC port, and the CA every client already pins."""
|
|
136
|
+
from .. import global_onboarding
|
|
137
|
+
|
|
138
|
+
host = global_onboarding.hostname()
|
|
139
|
+
port = global_onboarding.load().get("GLOBAL_RELAY_GRPC_PORT")
|
|
140
|
+
ca_path = Path(os.getenv("REMOTERF_CERT_DIR", global_onboarding.cfg_dir() / "certs")) / "ca.crt"
|
|
141
|
+
try:
|
|
142
|
+
ca_pem = ca_path.read_text("utf-8")
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
if not host or not port:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
return validate_route({"grpc_endpoint": f"{host}:{port}", "tls_server_name": host, "ca_pem": ca_pem})
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
@@ -168,6 +168,21 @@ class ReservationHandler:
|
|
|
168
168
|
username = unmap_arg(args['un'])
|
|
169
169
|
password = unmap_arg(args['pw'])
|
|
170
170
|
if self.login_user(username, password) == {"UC": map_arg(f'{username}')}:
|
|
171
|
+
if 'a' in args:
|
|
172
|
+
# A partner device: the owner answers, over the HOME's
|
|
173
|
+
# grant; the client then punches to the owner itself.
|
|
174
|
+
from .federation.home_devices import HomeDeviceError
|
|
175
|
+
|
|
176
|
+
if self.federation_devices is None:
|
|
177
|
+
return {"ace": map_arg("This home has no partner devices.")}
|
|
178
|
+
try:
|
|
179
|
+
answer = self.federation_devices.direct_offer(
|
|
180
|
+
local_account_id=self.get_uid(username),
|
|
181
|
+
token=unmap_arg(args['a']), ice=unmap_arg(args['ice']),
|
|
182
|
+
)
|
|
183
|
+
except HomeDeviceError as exc:
|
|
184
|
+
return {"ace": map_arg(str(exc))}
|
|
185
|
+
return {key: map_arg(value) for key, value in answer.items()}
|
|
171
186
|
from .direct_path import offer
|
|
172
187
|
return offer(self._session_hash(password), username, unmap_arg(args['ice']))
|
|
173
188
|
else:
|
|
@@ -1407,6 +1422,35 @@ class ReservationHandler:
|
|
|
1407
1422
|
)
|
|
1408
1423
|
return {"cancelled": True}
|
|
1409
1424
|
|
|
1425
|
+
@db_connection
|
|
1426
|
+
def federated_live_reservation(
|
|
1427
|
+
self,
|
|
1428
|
+
*,
|
|
1429
|
+
issuer_deployment_id: str,
|
|
1430
|
+
remote_subject_id: str,
|
|
1431
|
+
reservation_id: int,
|
|
1432
|
+
now: datetime | None = None,
|
|
1433
|
+
) -> dict | None:
|
|
1434
|
+
"""{local_account_id, device_id, username} for a federation-created
|
|
1435
|
+
reservation of this exact principal that is live right now, else None."""
|
|
1436
|
+
|
|
1437
|
+
row = self.cursor.execute(
|
|
1438
|
+
"SELECT f.local_account_id, r.device_id, r.username, r.start_time, r.end_time "
|
|
1439
|
+
"FROM FederatedReservations f JOIN Reservations r ON r.res_id = f.res_id "
|
|
1440
|
+
"WHERE f.res_id = ? AND f.issuer_deployment_id = ? AND f.remote_subject_id = ?",
|
|
1441
|
+
(int(reservation_id), canonical_uuid(issuer_deployment_id), canonical_uuid(remote_subject_id)),
|
|
1442
|
+
).fetchone()
|
|
1443
|
+
if row is None:
|
|
1444
|
+
return None
|
|
1445
|
+
moment = now or datetime.now()
|
|
1446
|
+
try:
|
|
1447
|
+
live = _parse_reservation_datetime(row[3]) <= moment < _parse_reservation_datetime(row[4])
|
|
1448
|
+
except ValueError:
|
|
1449
|
+
return None
|
|
1450
|
+
if not live:
|
|
1451
|
+
return None
|
|
1452
|
+
return {"local_account_id": int(row[0]), "device_id": int(row[1]), "username": row[2]}
|
|
1453
|
+
|
|
1410
1454
|
@db_connection
|
|
1411
1455
|
def federated_active_reservation(
|
|
1412
1456
|
self, *, local_account_id: int, device_id: int, now: datetime | None = None
|
|
@@ -170,10 +170,15 @@ class RpcManager:
|
|
|
170
170
|
def _federated_rpc(self, federated, *, function_name: str, args: dict) -> dict:
|
|
171
171
|
local_account_id, alias = federated
|
|
172
172
|
try:
|
|
173
|
-
|
|
173
|
+
results = reservation_handler.federation_devices.rpc(
|
|
174
174
|
local_account_id=local_account_id, alias=alias,
|
|
175
175
|
function_name=function_name, args=args,
|
|
176
176
|
)
|
|
177
|
+
if function_name.startswith("IDL:"):
|
|
178
|
+
# The Client learns here that this token drives a partner's
|
|
179
|
+
# device, and opens its direct path to that lab (Gate I).
|
|
180
|
+
results["federated"] = map_arg(alias["owner_deployment_id"])
|
|
181
|
+
return results
|
|
177
182
|
except Exception as e:
|
|
178
183
|
key = 'error' if function_name.startswith("IDL:") else 'a'
|
|
179
184
|
return {key: map_arg(str(e))}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remoteRF-server
|
|
3
|
-
Version: 1.1.0.
|
|
3
|
+
Version: 1.1.0.dev52
|
|
4
4
|
Summary: RemoteRF server-side control package
|
|
5
5
|
License: GPL-3.0-or-later
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -9,7 +9,7 @@ Classifier: Operating System :: OS Independent
|
|
|
9
9
|
Requires-Python: >=3.10
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist: remoterf-federation-core==0.
|
|
12
|
+
Requires-Dist: remoterf-federation-core==0.16.0
|
|
13
13
|
Requires-Dist: grpcio<2.0.0,>=1.81.1
|
|
14
14
|
Requires-Dist: protobuf<7.0.0,>=6.33.5
|
|
15
15
|
Requires-Dist: numpy
|
|
@@ -52,35 +52,36 @@ remoteRF_server/host/host_tunnel_server.py,sha256=sz06xOtFbaDZSy1UQfw6faKhJmuOy5
|
|
|
52
52
|
remoteRF_server/server/__init__.py,sha256=-1h_xQjiE66oiWRO-vnLGJ4smpgUNhFuV-ZreW9_tD4,667
|
|
53
53
|
remoteRF_server/server/acc_perms.py,sha256=2loCvstVu8uvsb2uCv2Sk7iNQ5GyO_sBIkPQFzqOaRI,13334
|
|
54
54
|
remoteRF_server/server/cert_provider.py,sha256=CRDrZL4DSf-eL6wzPJzwucrzPLOr2PvNRq5V--oZtr0,6272
|
|
55
|
-
remoteRF_server/server/commands.py,sha256=
|
|
55
|
+
remoteRF_server/server/commands.py,sha256=J70-S7TdpD5vkjUqDLnGhWv5bwNogNx9GAp85LHrACE,17701
|
|
56
56
|
remoteRF_server/server/console.py,sha256=J91LfwPVmBgielOxfE740NOYduekggyZkqi-47y_A4w,4472
|
|
57
57
|
remoteRF_server/server/control.py,sha256=Uq1Fk0zIoHHX_EXfz3OuNle8urvO_UhAiB5Lk5cs2e4,5177
|
|
58
58
|
remoteRF_server/server/device_manager.py,sha256=PPuCwlPSmjfVpCqy_hCaOr9WvFgE8mqtLMhWOAgXNDA,39281
|
|
59
|
-
remoteRF_server/server/direct_path.py,sha256=
|
|
59
|
+
remoteRF_server/server/direct_path.py,sha256=gXBNNf5eG7NSe0YpkAz1n874LRzfRPbSNJ-MAgRFCR0,19995
|
|
60
60
|
remoteRF_server/server/dynamic_v2.py,sha256=AMhQEFhNAvCNcF-PdfCCQQLmeoaR7s1Os8et1KFGn_8,70814
|
|
61
61
|
remoteRF_server/server/global_onboarding.py,sha256=NcKG8TYhCJEiaCtdXL1jEgVm-R_xWpOIj5KI4bWfkr0,39942
|
|
62
62
|
remoteRF_server/server/grpc_server.py,sha256=91FpJubzN_Wfuk6fW_owIEzNCukd333sAGW68FUU8pk,46787
|
|
63
63
|
remoteRF_server/server/host_dynamic_v2.py,sha256=1rJQwP4knSd0EXkByrSn_ryjLx81f6sp6SNYr_aEass,8105
|
|
64
|
-
remoteRF_server/server/reservation.py,sha256=
|
|
65
|
-
remoteRF_server/server/rpc_manager.py,sha256=
|
|
64
|
+
remoteRF_server/server/reservation.py,sha256=r7XE7lPj_KdRx8nCMcr0b0NJuUFzNFmdIXEQW4c8LY4,69959
|
|
65
|
+
remoteRF_server/server/rpc_manager.py,sha256=qG81UtoVRiamyaI0mHrxBOV3oYaDP6DHClV5no-ehpo,17529
|
|
66
66
|
remoteRF_server/server/user_group_cli.py,sha256=0QbsGfeIqkyU6E1naGwR-i7BxptPMMTQCi1m7yM03JM,26259
|
|
67
67
|
remoteRF_server/server/user_group_handler.py,sha256=1ax73W_P3H6GDxQS13evwA7G7skIElFVLuS0MFqsFyU,41780
|
|
68
68
|
remoteRF_server/server/v2_errors.py,sha256=IS3hIhf5tDe7jaOrDAVQPjGBpqAjN7442BlJYgfJo7g,3331
|
|
69
69
|
remoteRF_server/server/deployment/__init__.py,sha256=YV86uafd8XiGD7sLaqNfO5xxmt5te2R6DUhD-w1lZuY,77
|
|
70
70
|
remoteRF_server/server/deployment/capabilities.py,sha256=20RxmGG03VoQQZnV3jGhc-3i9B5hOl5fCopnsErFjPk,872
|
|
71
|
-
remoteRF_server/server/federation/__init__.py,sha256=
|
|
71
|
+
remoteRF_server/server/federation/__init__.py,sha256=CxdBYJpp1uFwOpC1NpDMxrDOXjxk_4vjUSpVI6vFE14,735
|
|
72
72
|
remoteRF_server/server/federation/admin.py,sha256=t3F17FIytekzclPICmaFJEA4-F5OuWcDx3rgmFga17A,9187
|
|
73
|
-
remoteRF_server/server/federation/cli.py,sha256=
|
|
74
|
-
remoteRF_server/server/federation/cluster.py,sha256=
|
|
73
|
+
remoteRF_server/server/federation/cli.py,sha256=S7K7dM3h-WlJ6sGiVj47J26PXte39gVPx_vc-cAQwVs,18559
|
|
74
|
+
remoteRF_server/server/federation/cluster.py,sha256=MKg6vE7WIz33zl2-yghnutIdDEXz6aGJDv-Uo-cXwk8,39215
|
|
75
75
|
remoteRF_server/server/federation/contracts.py,sha256=NYh7lb_9etJHFfzXwevjC4HfSBUypLxg5xGeBgydLF4,29305
|
|
76
76
|
remoteRF_server/server/federation/destination.py,sha256=XawmGCiVbL_MfNNn8xwV9_bYxUDW1L7efCpL11K2JAc,35909
|
|
77
|
-
remoteRF_server/server/federation/devices.py,sha256=
|
|
77
|
+
remoteRF_server/server/federation/devices.py,sha256=2Wwt7kRHx_aVHaJpBlIWrnryAGWviVM7_ZVXKaie8LY,24257
|
|
78
78
|
remoteRF_server/server/federation/enrollment.py,sha256=HsyyPpMzuQ289sxHj4XLTbiCVpp3er1YXBXQ6OZyEfw,23168
|
|
79
|
-
remoteRF_server/server/federation/home.py,sha256
|
|
80
|
-
remoteRF_server/server/federation/home_devices.py,sha256=
|
|
79
|
+
remoteRF_server/server/federation/home.py,sha256=x80xTGyS4zRQLAVtYOW0XMN3dceW_xE3Yhk8z7Tw5Ko,27006
|
|
80
|
+
remoteRF_server/server/federation/home_devices.py,sha256=NQaIw9xPS8oNwRWOm3WU6L0wHgQ1TJgzWCGUPvRWcx0,20302
|
|
81
81
|
remoteRF_server/server/federation/identity.py,sha256=UtQJKm2mChWZto0ZCg385k_ObrwOUtxY7XFyU_s5Q10,7436
|
|
82
82
|
remoteRF_server/server/federation/legacy_state.py,sha256=SpeTZHFqymNYGWG7A3B-78dIRIVLYltBbE2zGDWzt-Q,19838
|
|
83
|
-
remoteRF_server/server/federation/migrations.py,sha256=
|
|
83
|
+
remoteRF_server/server/federation/migrations.py,sha256=bKdURVs4jmFYzIJXrgtmspAcg1_UKStg4JMAEwlsuuY,53270
|
|
84
|
+
remoteRF_server/server/federation/peers.py,sha256=VsmI_5tWciLltDCtQL84bMjdk_ZAwwr4e2ATXDMH15M,6618
|
|
84
85
|
remoteRF_server/server/federation/permissions.py,sha256=ou4uFsMAGHnDeBW6zbpGeUTwp0thlYXT5nIAgd3B1rg,15861
|
|
85
86
|
remoteRF_server/server/federation/principals.py,sha256=CsRYa8POmtMaxKzAGwY14sxBTNS5uKjM1nUE19mjpoQ,10410
|
|
86
87
|
remoteRF_server/server/federation/runtime.py,sha256=5CoDlu0jSQrFdwRoGL2Z4_CWxnA46B3j6-eNQo8N2FI,7624
|
|
@@ -90,9 +91,9 @@ remoteRF_server/tools/gen_certs.py,sha256=Vbyu8JjRu__UYPVmyCRXt5SOw0Za1HV7g1md3G
|
|
|
90
91
|
remoteRF_server/tools/gist_status.py,sha256=SFv2iQgQrIKT9gxKrU-_Dr7S0p0rE4wQM6J3OC6FcoM,4649
|
|
91
92
|
remoteRF_server/tools/gist_status_testing.py,sha256=IYw7UkPjAUpKDTtIlnYGb5g6SJkwz4TRGUfJJ6f4LhQ,2690
|
|
92
93
|
remoteRF_server/tools/introspect_uhd.py,sha256=vUpPb0rM9tsHPxWadS3EfMDE-gMd-TvNV0TjrZOQqBY,4531
|
|
93
|
-
remoterf_server-1.1.0.
|
|
94
|
-
remoterf_server-1.1.0.
|
|
95
|
-
remoterf_server-1.1.0.
|
|
96
|
-
remoterf_server-1.1.0.
|
|
97
|
-
remoterf_server-1.1.0.
|
|
98
|
-
remoterf_server-1.1.0.
|
|
94
|
+
remoterf_server-1.1.0.dev52.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
95
|
+
remoterf_server-1.1.0.dev52.dist-info/METADATA,sha256=LmVnJnoF5UA5wujJps0P7UC8xRVFb3PYy6RejNW310o,42069
|
|
96
|
+
remoterf_server-1.1.0.dev52.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
97
|
+
remoterf_server-1.1.0.dev52.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
|
|
98
|
+
remoterf_server-1.1.0.dev52.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
|
|
99
|
+
remoterf_server-1.1.0.dev52.dist-info/RECORD,,
|
|
File without changes
|
{remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{remoterf_server-1.1.0.dev50.dist-info → remoterf_server-1.1.0.dev52.dist-info}/top_level.txt
RENAMED
|
File without changes
|