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.
@@ -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
- def _dispatch(request: grpc_pb2.GenericRPCRequest, peer_host: str) -> dict:
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
- if not (name.startswith("IDL:") or rpc_manager._looks_like_device_rpc(name)):
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
- results = await asyncio.get_running_loop().run_in_executor(
345
- None, _dispatch, request, self.peer[0]
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
- ).start()
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 and stop the loop (server stop, tests)."""
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.15.0":
9
+ if _federation_core.__version__ != "0.16.0":
10
10
  raise RuntimeError(
11
- "RemoteRF Server federation requires remoterf-federation-core==0.15.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 when they change."""
757
- sharing = _sharing(name)
758
- if sharing == cluster.get("status"):
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()), **sharing}
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"] = sharing
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
- # The one control-plane RPC a federated caller may run without a live
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
- if function_name == self.IDL_FUNCTION:
383
- # Pin the schema lookup to the device the grant named; a
384
- # caller-supplied name or id would otherwise widen it.
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 five device actions.
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 migrate_gate_h_usergroups_schema
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
- migrate_gate_h_usergroups_schema(conn)
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 and broker credentials per attempt, with one bounded retry."""
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
- received = (
557
- self.sender(body, outer, deadline)
558
- if self._sender_accepts_deadline
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
- except (requests.RequestException, TimeoutError, ConnectionError, OSError):
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
- return reservation_handler.federation_devices.rpc(
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.dev50
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.15.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=ccb_Ab1mbIkosBsFGJnDSccrjWtR_Lymvvn3IqhOnyc,17519
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=TrL0aWCUXN9Lmuww-v_AXtLIOqTkckm2vPW91xcnOLI,17875
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=BQJLGNVAdKMZu2Lg4DGO9UhuJ0b1GSF8K1DgIOFrYUg,67816
65
- remoteRF_server/server/rpc_manager.py,sha256=BDvsTSVRxwNF2ujuMbIb5TYNAUI6AH4xXBuXrb8lltM,17223
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=auqdGiGnEGIKQiXX2jFVt8iWmfyZ-V3w4dUmtWDKAZE,735
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=TIPfOdYxYDok7pR5g6COf_jUlcTFcj9o728kkLKcDqs,16351
74
- remoteRF_server/server/federation/cluster.py,sha256=LunLUF5tk-kOjKV3DIxPTaeGow2zvOZsae2fs1XBSQE,38469
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=n5MvnD0UZyl4sFpiekG9ProanWXTYizYq6YchCflLgY,18061
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=-_0__wJN50Y1-Dd396_iUZjGESBkoBNGISXb8gbLrjM,23982
80
- remoteRF_server/server/federation/home_devices.py,sha256=lbsw201HBOYPP5vbN172vdm2O_ouCdZBo1cRYCVScmg,18754
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=opPIfoNvR3BwoN9BaZsuEs14cT-zz4ghXWHV0hno7QI,50958
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.dev50.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
- remoterf_server-1.1.0.dev50.dist-info/METADATA,sha256=jy_xvhBrCSFGJ5x5I_ZBgDynd1Hwe7uFv-1KaC-Fs60,42069
95
- remoterf_server-1.1.0.dev50.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
- remoterf_server-1.1.0.dev50.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
- remoterf_server-1.1.0.dev50.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
- remoterf_server-1.1.0.dev50.dist-info/RECORD,,
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,,