remoteRF-server 1.4.9__tar.gz → 1.4.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/PKG-INFO +3 -2
  2. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/README.md +2 -1
  3. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/pyproject.toml +1 -1
  4. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/commands.py +1 -0
  5. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/cluster.py +101 -6
  6. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/devices.py +6 -0
  7. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/enrollment.py +42 -0
  8. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/home.py +28 -2
  9. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/migrations.py +7 -0
  10. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/permissions.py +2 -0
  11. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/principals.py +61 -0
  12. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/global_onboarding.py +15 -4
  13. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/grpc_server.py +48 -0
  14. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/reservation.py +22 -0
  15. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/user_group_handler.py +8 -5
  16. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/PKG-INFO +3 -2
  17. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/SOURCES.txt +1 -0
  18. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_global_onboarding.py +20 -3
  19. remoterf_server-1.4.10/tests/test_proxy_lifecycle.py +258 -0
  20. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/LICENSE +0 -0
  21. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/setup.cfg +0 -0
  22. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/__init__.py +0 -0
  23. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/__init__.py +0 -0
  24. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/__init__.py +0 -0
  25. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/deployment_capabilities.proto +0 -0
  26. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2.py +0 -0
  27. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  28. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/federation.proto +0 -0
  29. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/federation_pb2.py +0 -0
  30. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/federation_pb2_grpc.py +0 -0
  31. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc.proto +0 -0
  32. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc_host.proto +0 -0
  33. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
  34. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
  35. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
  36. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
  37. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/grpc/v2_codec.py +0 -0
  38. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/__init__.py +0 -0
  39. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/device_schema.py +0 -0
  40. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/pluto_mimo_schema.py +0 -0
  41. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/pluto_schema.py +0 -0
  42. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/schema.py +0 -0
  43. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/idl/v2.py +0 -0
  44. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/__init__.py +0 -0
  45. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
  46. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/api_token.py +0 -0
  47. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/db_connection.py +0 -0
  48. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/db_location.py +0 -0
  49. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/list_string.py +0 -0
  50. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/panel.py +0 -0
  51. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/common/utils/process_arg.py +0 -0
  52. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/__init__.py +0 -0
  53. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  54. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/pluto_diag.py +0 -0
  55. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/pluto_mimo_schema.py +0 -0
  56. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +0 -0
  57. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/pluto_schema.py +0 -0
  58. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/adalm_pluto/poe.py +0 -0
  59. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/bluetooth_diag.py +0 -0
  60. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/gps_diag.py +0 -0
  61. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/hackrf/__init__.py +0 -0
  62. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/hackrf/hackrf_schema.py +0 -0
  63. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/rtl_sdr/__init__.py +0 -0
  64. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_diag.py +0 -0
  65. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_schema.py +0 -0
  66. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/ti_mmwave/__init__.py +0 -0
  67. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/ti_mmwave/ti_mmwave_schema.py +0 -0
  68. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/__init__.py +0 -0
  69. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/policy.py +0 -0
  70. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/profiles.py +0 -0
  71. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/uhd_manifest.py +0 -0
  72. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/usrp_diag.py +0 -0
  73. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp/usrp_schema.py +0 -0
  74. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp_phaser/__init__.py +0 -0
  75. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/drivers/usrp_phaser/usrp_phaser_schema.py +0 -0
  76. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/host/__init__.py +0 -0
  77. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/host/host_auth_token.py +0 -0
  78. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/host/host_directory_store.py +0 -0
  79. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
  80. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/__init__.py +0 -0
  81. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/acc_perms.py +0 -0
  82. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/cert_provider.py +0 -0
  83. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/console.py +0 -0
  84. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/control.py +0 -0
  85. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/deployment/__init__.py +0 -0
  86. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/deployment/capabilities.py +0 -0
  87. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/device_manager.py +0 -0
  88. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/diag.py +0 -0
  89. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/direct_path.py +0 -0
  90. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/dynamic_v2.py +0 -0
  91. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/dynamic_v2_bridge.py +0 -0
  92. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/__init__.py +0 -0
  93. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/admin.py +0 -0
  94. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/cli.py +0 -0
  95. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/contracts.py +0 -0
  96. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/destination.py +0 -0
  97. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/global_home.py +0 -0
  98. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/home_devices.py +0 -0
  99. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/identity.py +0 -0
  100. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/legacy_state.py +0 -0
  101. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/peers.py +0 -0
  102. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/runtime.py +0 -0
  103. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/federation/trust.py +0 -0
  104. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/host_dynamic_v2.py +0 -0
  105. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/rpc_manager.py +0 -0
  106. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/startup_check.py +0 -0
  107. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/user_group_cli.py +0 -0
  108. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/server/v2_errors.py +0 -0
  109. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/serverrf_cli.py +0 -0
  110. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/tools/__init__.py +0 -0
  111. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/tools/gen_certs.py +0 -0
  112. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/tools/gist_status.py +0 -0
  113. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/tools/gist_status_testing.py +0 -0
  114. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server/tools/introspect_uhd.py +0 -0
  115. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
  116. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
  117. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/requires.txt +0 -0
  118. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/src/remoteRF_server.egg-info/top_level.txt +0 -0
  119. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_account_hardening.py +0 -0
  120. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_account_names.py +0 -0
  121. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_account_removal.py +0 -0
  122. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_activity_log.py +0 -0
  123. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_availability.py +0 -0
  124. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_bind_host.py +0 -0
  125. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_cert_provider.py +0 -0
  126. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_client_sessions.py +0 -0
  127. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_cluster.py +0 -0
  128. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_commands.py +0 -0
  129. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_console.py +0 -0
  130. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_contrib_drivers.py +0 -0
  131. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_default_rtl_sdr_schema.py +0 -0
  132. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_default_usrp_schema.py +0 -0
  133. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_deployment_capabilities.py +0 -0
  134. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_detached_device_rpc.py +0 -0
  135. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_device_handles.py +0 -0
  136. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_device_init.py +0 -0
  137. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_devices_add_wizard.py +0 -0
  138. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_devices_list.py +0 -0
  139. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_diag.py +0 -0
  140. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_direct_fixed_port.py +0 -0
  141. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_direct_path_server.py +0 -0
  142. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_dynamic_v2.py +0 -0
  143. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_federated_direct_path.py +0 -0
  144. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_federated_dynamic_v2.py +0 -0
  145. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_federation_edges.py +0 -0
  146. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_first_run.py +0 -0
  147. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_d_contracts.py +0 -0
  148. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_e_federation.py +0 -0
  149. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_e_proto.py +0 -0
  150. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_f_enrollment.py +0 -0
  151. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_g1_logging.py +0 -0
  152. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_g_permissions.py +0 -0
  153. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_h_devices.py +0 -0
  154. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gate_i_direct_path.py +0 -0
  155. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_gen_certs.py +0 -0
  156. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_global_accounts_migration.py +0 -0
  157. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_global_home.py +0 -0
  158. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_global_v1_disposal.py +0 -0
  159. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_hackrf_schema.py +0 -0
  160. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_home_device_plane.py +0 -0
  161. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_host_directory_store.py +0 -0
  162. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_host_dynamic_v2.py +0 -0
  163. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_idl_schema.py +0 -0
  164. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_panel.py +0 -0
  165. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_peer_transport.py +0 -0
  166. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_pluto_compat.py +0 -0
  167. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_pluto_poe.py +0 -0
  168. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_process_arg.py +0 -0
  169. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_reservation_handoff.py +0 -0
  170. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_reservation_policy_b.py +0 -0
  171. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_reservation_timestamps.py +0 -0
  172. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_rpc_manager_batch.py +0 -0
  173. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_rpc_manager_calln.py +0 -0
  174. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_rpc_manager_rx_n.py +0 -0
  175. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_rtl_sdr_schema.py +0 -0
  176. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_soak_federation.py +0 -0
  177. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_startup_check.py +0 -0
  178. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_status_push.py +0 -0
  179. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_ti_mmwave_hardware.py +0 -0
  180. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_ti_mmwave_schema.py +0 -0
  181. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_user_group_cli.py +0 -0
  182. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_b205mini_uhd410_hardware.py +0 -0
  183. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_client_server_hardware.py +0 -0
  184. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_client_server_localhost.py +0 -0
  185. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_n210_client_server_hardware.py +0 -0
  186. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_preload.py +0 -0
  187. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_profiles.py +0 -0
  188. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_remoterf_rx_hardware.py +0 -0
  189. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_usrp_uhd410_hardware.py +0 -0
  190. {remoterf_server-1.4.9 → remoterf_server-1.4.10}/tests/test_wipe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.4.9
3
+ Version: 1.4.10
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -881,10 +881,11 @@ After running `serverrf --serve`, you’ll enter the **RemoteRF Server Shell**.
881
881
 
882
882
  ### Users
883
883
 
884
- * `users list` — List all accounts
884
+ * `users list` — List all accounts (this lab's own first, then visitors from partner labs)
885
885
  * `users manage` — Manage a specific user (perms / delete / reservations)
886
886
  * `users purge` — Delete **all** users
887
887
  * `users perms` — Show permissions table
888
+ * `users prune-proxies [--stale DAYS] [-y]` — List federated proxies whose home lab is gone (dry run); `-y` removes them
888
889
 
889
890
  ---
890
891
 
@@ -850,10 +850,11 @@ After running `serverrf --serve`, you’ll enter the **RemoteRF Server Shell**.
850
850
 
851
851
  ### Users
852
852
 
853
- * `users list` — List all accounts
853
+ * `users list` — List all accounts (this lab's own first, then visitors from partner labs)
854
854
  * `users manage` — Manage a specific user (perms / delete / reservations)
855
855
  * `users purge` — Delete **all** users
856
856
  * `users perms` — Show permissions table
857
+ * `users prune-proxies [--stale DAYS] [-y]` — List federated proxies whose home lab is gone (dry run); `-y` removes them
857
858
 
858
859
  ---
859
860
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoteRF-server"
7
- version = "1.4.9"
7
+ version = "1.4.10"
8
8
  description = "RemoteRF server-side control package"
9
9
  license = { text = "GPL-3.0-or-later" }
10
10
  requires-python = ">=3.10"
@@ -116,6 +116,7 @@ def _table() -> list[Command]:
116
116
  Command(("users manage",), "Change a user's role (interactive)", _daemon("cmd_users_manage"), "Users", where=D, interactive=True),
117
117
  Command(("users perms",), "Show permissions", _daemon("cmd_users_perms"), "Users", where=D),
118
118
  Command(("users purge",), "Remove ALL accounts", _daemon("cmd_users_purge"), "Users", where=D),
119
+ Command(("users prune-proxies",), "Partner-lab visitors whose home is gone: list them, -y removes [--stale DAYS]", _daemon("cmd_users_prune_proxies"), "Users", where=D),
119
120
  # ---- reservations ----
120
121
  Command(("reservations list",), "List reservations", _daemon("cmd_reservations_list"), "Reservations", where=D),
121
122
  Command(("reservations purge",), "Remove ALL reservations", _daemon("cmd_reservations_purge"), "Reservations", where=D),
@@ -51,10 +51,11 @@ from pathlib import Path
51
51
  from typing import Optional
52
52
  from uuid import uuid4
53
53
 
54
- from remoterf_federation_core import ContractError, FederationError, TransportFailure, canonical_json
54
+ from remoterf_federation_core import ContractError, FederationError, OwnerSignedFailure, TransportFailure, canonical_json
55
55
 
56
56
  from .. import global_onboarding as g
57
57
  from .contracts import ContractStore
58
+ from .enrollment import UNENROLL_CODE
58
59
  from .migrations import migrate_gate_f_usergroups_schema
59
60
  from .trust import TrustedKeyStore
60
61
 
@@ -760,9 +761,9 @@ def admit_account(home, local_account_id: int) -> None:
760
761
 
761
762
 
762
763
  def forget_account(local_account_id: int | None = None) -> None:
763
- """A deleted account leaves every cluster, grants and all, so the tick
764
- stops trying to enrol a ghost. None: every account (a purge)."""
765
- where, args = ("local_account_id=? AND kind='local'", (int(local_account_id),)) if local_account_id is not None else ("1", ())
764
+ """A deleted account (native or proxy) leaves every cluster, grants and
765
+ all, so the tick stops trying to enrol a ghost. None: every account (a purge)."""
766
+ where, args = ("local_account_id=?", (int(local_account_id),)) if local_account_id is not None else ("1", ())
766
767
  with _usergroups() as conn:
767
768
  rows = conn.execute(f"SELECT DISTINCT cluster_name, local_account_id FROM ClusterAccounts WHERE {where}", args).fetchall()
768
769
  for name, account in rows:
@@ -861,6 +862,33 @@ def redeem_code(local_account_id: int, code_name: str) -> Optional[str]:
861
862
 
862
863
  _ENROL_RETRY_FIRST, _ENROL_RETRY_CAP = 30.0, 600.0
863
864
  _unreachable: dict[str, tuple[float, float]] = {} # peer -> (retry_at, delay), monotonic
865
+ _revoke_retry: dict[str, tuple[float, float]] = {} # same, for unenrols
866
+ REVOCATION_TTL = 180 * 86400 # an unenrol for a partner we hold no contract with waits this long
867
+
868
+
869
+ def _back_off(table: dict, peer: str) -> None:
870
+ delay = min(table.get(peer, (0.0, _ENROL_RETRY_FIRST / 2))[1] * 2, _ENROL_RETRY_CAP)
871
+ table[peer] = (time.monotonic() + delay, delay)
872
+
873
+
874
+ def _forget_departed(plane) -> None:
875
+ """Drop enrolment records at partners we no longer hold a live contract
876
+ with: that partner may prune our users as "issuer gone", and on a rejoin
877
+ the next tick enrols everyone again (an existing proxy just stays)."""
878
+ if getattr(plane, "path", None) is None:
879
+ return
880
+ try:
881
+ with _usergroups() as conn:
882
+ live = {deployment for row in conn.execute(
883
+ "SELECT deployment_a, deployment_b FROM FederationContracts "
884
+ "WHERE state='active' AND superseded_by_version IS NULL") for deployment in row}
885
+ with closing(sqlite3.connect(plane.path, timeout=10.0)) as conn, conn:
886
+ peers = [row[0] for row in conn.execute("SELECT DISTINCT destination_deployment_id FROM FederatedEnrollments")]
887
+ for peer in peers:
888
+ if peer not in live:
889
+ conn.execute("DELETE FROM FederatedEnrollments WHERE destination_deployment_id=?", (peer,))
890
+ except sqlite3.OperationalError:
891
+ pass # no contracts or HOME device plane here yet
864
892
 
865
893
 
866
894
  def enroll_students(home, notify=None, *, only_account: int | None = None) -> None:
@@ -870,6 +898,7 @@ def enroll_students(home, notify=None, *, only_account: int | None = None) -> No
870
898
  enroller, plane = getattr(home, "federation_enroller", None), getattr(home, "federation_devices", None)
871
899
  if enroller is None or plane is None:
872
900
  return
901
+ _forget_departed(plane)
873
902
  clusters = load()
874
903
  with _usergroups() as conn:
875
904
  rows = conn.execute(
@@ -889,6 +918,7 @@ def enroll_students(home, notify=None, *, only_account: int | None = None) -> No
889
918
  continue # a lab nobody chose gets our accounts only once it shares a device
890
919
  if _unreachable.get(peer, (0.0, 0.0))[0] > time.monotonic():
891
920
  continue # unreachable a moment ago: back off rather than redial every tick
921
+ subject = _subject_of(plane, account)
892
922
  try:
893
923
  enroller.outbound.execute(
894
924
  local_account_id=account, destination_deployment_id=peer,
@@ -897,13 +927,76 @@ def enroll_students(home, notify=None, *, only_account: int | None = None) -> No
897
927
  )
898
928
  except (FederationError, OSError) as exc:
899
929
  if isinstance(exc, TransportFailure) and exc.code == "route_unavailable":
900
- delay = min(_unreachable.get(peer, (0.0, _ENROL_RETRY_FIRST / 2))[1] * 2, _ENROL_RETRY_CAP)
901
- _unreachable[peer] = (time.monotonic() + delay, delay)
930
+ _back_off(_unreachable, peer)
902
931
  notify(f"[cluster] {name}: could not enrol account {account} at "
903
932
  f"{party.get('display_name') or peer}: {getattr(exc, 'code', exc)}")
904
933
  continue
905
934
  _unreachable.pop(peer, None)
906
935
  plane.record_enrollment(local_account_id=account, destination_deployment_id=peer)
936
+ if subject and _subject_of(plane, account) is None:
937
+ # Deleted while the enrolment was in flight: its delete queued
938
+ # nothing for this peer, so queue the unenrol here.
939
+ with closing(sqlite3.connect(plane.path, timeout=10.0)) as conn, conn:
940
+ conn.execute("INSERT OR IGNORE INTO FederatedRevocations VALUES (?,?,?)",
941
+ (subject, peer, int(time.time())))
942
+ conn.execute("DELETE FROM FederatedEnrollments WHERE local_account_id=?", (account,))
943
+
944
+
945
+ def _subject_of(plane, account: int) -> str | None:
946
+ if getattr(plane, "path", None) is None:
947
+ return None
948
+ with closing(sqlite3.connect(plane.path, timeout=10.0)) as conn:
949
+ row = conn.execute("SELECT subject_id FROM LocalAccountSubjects WHERE local_account_id=?",
950
+ (account,)).fetchone()
951
+ return row[0] if row else None
952
+
953
+
954
+ def deliver_revocations(home, notify=None) -> None:
955
+ """HOME: tell each partner a deleted account was enrolled at to drop its
956
+ proxy. A row stays until the partner answers; an older partner answers
957
+ code_invalid (keeps its orphan). A partner we hold no contract with right
958
+ now (left, kicked, past the candidate cap) keeps its row until it is back
959
+ or REVOCATION_TTL runs out."""
960
+ notify = notify or (lambda text: None)
961
+ enroller, plane = getattr(home, "federation_enroller", None), getattr(home, "federation_devices", None)
962
+ if enroller is None or plane is None:
963
+ return
964
+ path = _db_path().with_name("reservations.db")
965
+ try:
966
+ with closing(sqlite3.connect(path, timeout=10.0)) as conn:
967
+ rows = conn.execute("SELECT subject_id, destination_deployment_id, queued_at FROM FederatedRevocations").fetchall()
968
+ except sqlite3.OperationalError:
969
+ return # no HOME device plane here yet
970
+ if not rows:
971
+ return
972
+ contracts = {}
973
+ for candidate in plane.permissions._candidates(int(plane.clock()), "FEDERATED_ENROLL")[0]:
974
+ contracts.setdefault(candidate["deployment_id"], candidate)
975
+ now = time.time()
976
+ for subject, peer, queued_at in rows:
977
+ contract = contracts.get(peer)
978
+ if contract is None:
979
+ if int(queued_at) > now - REVOCATION_TTL:
980
+ continue # no contract right now: wait for one, the TTL lets go of a partner that is gone
981
+ else:
982
+ if _revoke_retry.get(peer, (0.0, 0.0))[0] > time.monotonic():
983
+ continue # failed a moment ago: skip this peer's rows until the backoff runs out
984
+ try:
985
+ enroller.outbound.execute(
986
+ local_account_id=None, revoked_subject_id=subject, destination_deployment_id=peer,
987
+ contract_id=contract["contract_id"], contract_version=contract["contract_version"],
988
+ action="federated_enroll", code_name=UNENROLL_CODE,
989
+ )
990
+ except (FederationError, OSError) as exc:
991
+ # code_invalid (signed): a partner too old to know the code keeps its orphan.
992
+ if not (isinstance(exc, OwnerSignedFailure) and exc.code == "code_invalid"):
993
+ _back_off(_revoke_retry, peer)
994
+ notify(f"[cluster] could not remove a deleted account at {peer}: {getattr(exc, 'code', exc)}")
995
+ continue
996
+ _revoke_retry.pop(peer, None)
997
+ with closing(sqlite3.connect(path, timeout=10.0)) as conn, conn:
998
+ conn.execute("DELETE FROM FederatedRevocations WHERE subject_id=? AND destination_deployment_id=?",
999
+ (subject, peer))
907
1000
 
908
1001
 
909
1002
  # -----------------------------
@@ -1183,6 +1276,8 @@ def tick(notify=None, home=None) -> None:
1183
1276
  """One agent pass: move every cluster and member one step closer to active,
1184
1277
  then (given the HOME runtime) enrol students wherever they are missing."""
1185
1278
  notify = notify or (lambda text: None)
1279
+ if home is not None:
1280
+ deliver_revocations(home, notify)
1186
1281
  # Rosters, status notes and the public clusters: one GET for the whole pass.
1187
1282
  board = g.call("GET", "/v2/broker/clusters/roster")
1188
1283
  _walk_in(board.get("pools", []), notify)
@@ -210,6 +210,10 @@ class _DeviceHandlerBase:
210
210
  raise FederationBusinessError("principal_unknown")
211
211
  if row[2] != "active":
212
212
  raise FederationBusinessError("principal_disabled")
213
+ # At most hourly: `users prune-proxies --stale` reads it as the visitor's last activity.
214
+ now = int(time.time())
215
+ tx.execute("UPDATE FederatedPrincipals SET last_seen_at=? WHERE id=? AND last_seen_at<?",
216
+ (now, row[0], now - 3600))
213
217
  return str(row[0]), int(row[1])
214
218
 
215
219
  # -- result shaping ------------------------------------------------
@@ -669,6 +673,8 @@ def build_server_gate_h_executor(
669
673
  signing_kid=signing_kid,
670
674
  display_name=display_name,
671
675
  clock=clock,
676
+ account_remover=(lambda username: reservation_handler.remove_user(username=username))
677
+ if reservation_handler is not None else None,
672
678
  )
673
679
  for action, handler in build_device_handlers(
674
680
  reservations_path=reservations_path,
@@ -27,6 +27,9 @@ from remoterf_federation_core import (
27
27
  from .destination import ActionRegistry, FederationBusinessError, ServerFederationExecutor
28
28
 
29
29
  CLUSTER_CODE = "cluster"
30
+ # A HOME that deleted the account tells each partner to drop its proxy. The
31
+ # `cluster.` prefix is reserved and minted codes are uppercase: no collision.
32
+ UNENROLL_CODE = "cluster.unenroll"
30
33
  from .migrations import configure_connection, migrate_gate_f_usergroups_schema
31
34
  from .principals import PreparedFederatedAccount, prepare_federated_account
32
35
 
@@ -310,6 +313,7 @@ class ServerFederatedEnrollHandler:
310
313
  perms_path=None,
311
314
  clock=None,
312
315
  account_preparer=prepare_federated_account,
316
+ account_remover=None,
313
317
  ):
314
318
  self.usergroups_path = Path(usergroups_path)
315
319
  self.reservations_path = Path(reservations_path)
@@ -318,6 +322,7 @@ class ServerFederatedEnrollHandler:
318
322
  self.display_name = str(display_name)
319
323
  self.clock = clock or (lambda: int(time.time()))
320
324
  self.account_preparer = account_preparer
325
+ self.account_remover = account_remover or _remove_account
321
326
  with sqlite3.connect(self.usergroups_path) as conn:
322
327
  migrate_gate_f_usergroups_schema(conn)
323
328
 
@@ -325,6 +330,18 @@ class ServerFederatedEnrollHandler:
325
330
  """Never mutates owner authorization; invalid cases wait for owner tx."""
326
331
  claims = action.claims
327
332
  payload = action.request.value["payload"]
333
+ if payload["code_name"] == UNENROLL_CODE:
334
+ # Runs only for a verified, current grant, and only ever touches
335
+ # the grant's own (iss, sub): a lab can revoke nobody else's users.
336
+ with sqlite3.connect(self.reservations_path) as conn:
337
+ row = conn.execute(
338
+ "SELECT a.username FROM FederatedAccountBindings b JOIN Accounts a ON a.acc_id=b.local_account_id "
339
+ "WHERE b.issuer_deployment_id=? AND b.remote_subject_id=? AND a.auth_type='federated'",
340
+ (claims["iss"], claims["sub"]),
341
+ ).fetchone()
342
+ if row is not None:
343
+ self.account_remover(row[0])
344
+ return None
328
345
  if payload["code_name"] == CLUSTER_CODE:
329
346
  # No code to check: the home lab's signature under an active
330
347
  # cluster contract is the vouching. Prepare a proxy only once.
@@ -431,10 +448,29 @@ class ServerFederatedEnrollHandler:
431
448
  membership_present=True, groups=[{"group_id": "0", "group_name": f"cluster {name}"}], now=now,
432
449
  )
433
450
 
451
+ def _unenroll(self, tx, action, now):
452
+ """The proxy (and its reservations) went in prepare(); drop whatever
453
+ rows here still name it. Unknown subjects are fine: idempotent."""
454
+ claims = action.claims
455
+ principal = get_principal_tx(tx, claims["iss"], claims["sub"])
456
+ if principal is not None:
457
+ for table, column in (("UsersLookup", "user_id"), ("ClusterMemberships", "local_account_id"),
458
+ ("ClusterAccounts", "local_account_id")):
459
+ tx.execute(f"DELETE FROM {table} WHERE {column}=?", (int(principal[3]),))
460
+ tx.execute("DELETE FROM FederatedPrincipals WHERE id=?", (principal[0],))
461
+ self._audit(tx, action, code="ok", principal_id=None,
462
+ metadata={"unenrolled": principal is not None})
463
+ return self._result(
464
+ action, redemption_id=str(uuid5(NAMESPACE_URL, f"remoterf-unenroll:{claims['iss']}:{claims['sub']}")),
465
+ membership_present=False, groups=[], now=now,
466
+ )
467
+
434
468
  def __call__(self, tx, action):
435
469
  claims = action.claims
436
470
  payload = action.request.value["payload"]
437
471
  now = int(self.clock())
472
+ if payload["code_name"] == UNENROLL_CODE:
473
+ return self._unenroll(tx, action, now)
438
474
  if payload["code_name"] == CLUSTER_CODE:
439
475
  return self._cluster_enroll(tx, action, now)
440
476
  receipt = lookup_redemption_tx(
@@ -579,6 +615,12 @@ class ServerFederatedEnrollHandler:
579
615
  )
580
616
 
581
617
 
618
+ def _remove_account(username: str) -> None:
619
+ from ..reservation import reservation_handler
620
+
621
+ reservation_handler.remove_user(username=username)
622
+
623
+
582
624
  class ServerFederatedEnrollmentHome:
583
625
  def __init__(self, outbound):
584
626
  self.outbound = outbound
@@ -64,6 +64,7 @@ from remoterf_federation_core import (
64
64
  verify_eddsa_jwt,
65
65
  )
66
66
 
67
+ from .enrollment import UNENROLL_CODE
67
68
  from .migrations import configure_connection, migrate_gate_e_usergroups_schema
68
69
  from .peers import PeerStore
69
70
 
@@ -202,6 +203,28 @@ class ServerHomeGrantIssuer:
202
203
  raise FederationError("native_account_required")
203
204
  return canonical_uuid(row["subject_id"])
204
205
 
206
+ def _subject(self, conn, local_account_id, revoked_subject_id, destination, action, code_name) -> str:
207
+ """The native account's subject; or, for an unenrol only, a deleted
208
+ account's subject still queued for that partner."""
209
+ if revoked_subject_id is None:
210
+ if code_name == UNENROLL_CODE:
211
+ raise FederationError("native_account_required")
212
+ return self._native_subject(local_account_id, conn=conn)
213
+ subject = canonical_uuid(revoked_subject_id)
214
+ if local_account_id is not None or action != "federated_enroll" or code_name != UNENROLL_CODE:
215
+ raise FederationError("native_account_required")
216
+ try:
217
+ queued = conn.execute(
218
+ "SELECT 1 FROM FederatedRevocations WHERE subject_id=? AND destination_deployment_id=?",
219
+ (subject, destination),
220
+ ).fetchone()
221
+ live = conn.execute("SELECT 1 FROM LocalAccountSubjects WHERE subject_id=?", (subject,)).fetchone()
222
+ except sqlite3.OperationalError:
223
+ queued = None
224
+ if queued is None or live is not None:
225
+ raise FederationError("native_account_required")
226
+ return subject
227
+
205
228
  def issue(
206
229
  self,
207
230
  *,
@@ -215,6 +238,7 @@ class ServerHomeGrantIssuer:
215
238
  device_payload: Mapping | None = None,
216
239
  enrollee: Mapping | None = None,
217
240
  jti: str | None = None,
241
+ revoked_subject_id: str | None = None,
218
242
  ) -> IssuedGrant:
219
243
  destination = canonical_uuid(destination_deployment_id)
220
244
  request_id = canonical_uuid(request_id or str(uuid4()))
@@ -239,7 +263,7 @@ class ServerHomeGrantIssuer:
239
263
  try:
240
264
  configure_connection(subject_conn)
241
265
  subject_conn.execute("BEGIN IMMEDIATE")
242
- subject = self._native_subject(local_account_id, conn=subject_conn)
266
+ subject = self._subject(subject_conn, local_account_id, revoked_subject_id, destination, action, code_name)
243
267
  except Exception:
244
268
  conn.rollback()
245
269
  conn.close()
@@ -304,7 +328,7 @@ class ServerHomeGrantIssuer:
304
328
  issuance_hook = getattr(self, "_issuance_lock_hook", None)
305
329
  if issuance_hook is not None:
306
330
  issuance_hook()
307
- subject = self._native_subject(local_account_id, conn=subject_conn)
331
+ subject = self._subject(subject_conn, local_account_id, revoked_subject_id, destination, action, code_name)
308
332
  claims = build_grant_claims(
309
333
  issuer_deployment_id=self.local_deployment_id,
310
334
  subject_id=subject,
@@ -627,6 +651,7 @@ class HomeFederationOutbound:
627
651
  retry_ambiguous: bool = True,
628
652
  deadline: float | None = None,
629
653
  request_frame: bytes | None = None,
654
+ revoked_subject_id: str | None = None,
630
655
  ) -> dict:
631
656
  """Verified result; an ok device_rpc result also carries ``frame``, its
632
657
  raw response. With ``request_frame`` the caller leaves ``request_b64``
@@ -698,6 +723,7 @@ class HomeFederationOutbound:
698
723
  # A downgrade resend keeps the JTI: if the refusal was forged
699
724
  # after the first form ran, the destination answers "replayed".
700
725
  **({"jti": resend_jti} if resend_jti else {}),
726
+ **({"revoked_subject_id": revoked_subject_id} if revoked_subject_id else {}),
701
727
  )
702
728
  resend_jti = None
703
729
  frame = request_frame if detached else b""
@@ -478,6 +478,7 @@ _GATE_H_RESERVATIONS_CHECKSUM = hashlib.sha256(
478
478
  # HOME side of the device plane: which partners a native account is enrolled
479
479
  # at, a HOME-local integer id for each partner device the Client has seen, and
480
480
  # the HOME-minted reservation rows that route a device token to the owner.
481
+ # FederatedRevocations: deleted accounts' partners still to be told (the tick drains it).
481
482
  _HOME_DEVICES_RESERVATIONS_DDL = """
482
483
  CREATE TABLE IF NOT EXISTS FederatedEnrollments (
483
484
  local_account_id INTEGER NOT NULL,
@@ -485,6 +486,12 @@ CREATE TABLE IF NOT EXISTS FederatedEnrollments (
485
486
  enrolled_at INTEGER NOT NULL,
486
487
  PRIMARY KEY(local_account_id,destination_deployment_id)
487
488
  );
489
+ CREATE TABLE IF NOT EXISTS FederatedRevocations (
490
+ subject_id TEXT NOT NULL CHECK(length(subject_id)=36),
491
+ destination_deployment_id TEXT NOT NULL CHECK(length(destination_deployment_id)=36),
492
+ queued_at INTEGER NOT NULL,
493
+ PRIMARY KEY(subject_id,destination_deployment_id)
494
+ );
488
495
  CREATE TABLE IF NOT EXISTS FederatedDeviceAliases (
489
496
  alias_id INTEGER PRIMARY KEY,
490
497
  owner_deployment_id TEXT NOT NULL CHECK(length(owner_deployment_id)=36),
@@ -393,6 +393,7 @@ def build_server_gate_g_executor(
393
393
  display_name,
394
394
  perms_path=None,
395
395
  clock=None,
396
+ account_remover=None,
396
397
  ):
397
398
  """Final Phase-1 destination registry: enroll plus permissions read."""
398
399
 
@@ -406,6 +407,7 @@ def build_server_gate_g_executor(
406
407
  local_deployment_id=local_deployment_id,
407
408
  display_name=display_name,
408
409
  clock=clock,
410
+ account_remover=account_remover,
409
411
  )
410
412
  permissions = ServerPermissionsReadHandler(
411
413
  local_deployment_id=local_deployment_id,
@@ -267,3 +267,64 @@ class FederatedPrincipalStore:
267
267
  ).fetchone()
268
268
  assert final is not None
269
269
  return self._decode(final)
270
+
271
+
272
+ def orphan_proxies(reservations_path, usergroups_path, *, stale_days: int | None = None, now: int | None = None) -> list[dict]:
273
+ """Federated proxies nothing else will clean up: their home is no longer a
274
+ partner (left Global, kicked, contract revoked), half their rows are gone,
275
+ or -- only when asked -- inactive for ``stale_days``: no device action,
276
+ reservation or enrolment in that time and nothing booked ahead. A pruned
277
+ proxy whose home still lists it loses access here until that home re-enrols it."""
278
+ from contextlib import closing
279
+
280
+ now = int(time.time() if now is None else now)
281
+ with closing(sqlite3.connect(usergroups_path, timeout=10.0)) as conn:
282
+ principals = conn.execute(
283
+ "SELECT id, local_account_id, issuer_deployment_id, last_seen_at FROM FederatedPrincipals"
284
+ ).fetchall()
285
+ partners = {deployment for row in conn.execute(
286
+ "SELECT deployment_a, deployment_b FROM FederationContracts "
287
+ "WHERE state='active' AND superseded_by_version IS NULL") for deployment in row}
288
+ with closing(sqlite3.connect(reservations_path, timeout=10.0)) as conn:
289
+ accounts = dict(conn.execute("SELECT acc_id, username FROM Accounts WHERE auth_type='federated'").fetchall())
290
+ homes = dict(conn.execute("SELECT local_account_id, issuer_deployment_id FROM FederatedAccountBindings").fetchall())
291
+ ahead = dict(conn.execute(
292
+ "SELECT username, COUNT(*) FROM Reservations WHERE end_time > ? GROUP BY username", (datetime.now(),)
293
+ ).fetchall())
294
+ # Activity beyond the principal's own last_seen_at: past bookings not yet swept, and booking times.
295
+ active = {}
296
+ for username, end in conn.execute("SELECT username, MAX(end_time) FROM Reservations GROUP BY username"):
297
+ try:
298
+ active[username] = int(datetime.fromisoformat(str(end)).timestamp())
299
+ except ValueError:
300
+ pass
301
+ try:
302
+ booked = dict(conn.execute(
303
+ "SELECT local_account_id, MAX(created_at) FROM FederatedReservations GROUP BY local_account_id"))
304
+ except sqlite3.OperationalError:
305
+ booked = {}
306
+ out, seen = [], set()
307
+
308
+ def add(account, username, issuer, last_active, reason, principal_id=None):
309
+ out.append({"username": username, "local_account_id": account, "principal_id": principal_id,
310
+ "issuer": issuer, "last_active": last_active, "reservations": ahead.get(username, 0),
311
+ "reason": reason})
312
+
313
+ for principal_id, account, issuer, last_seen in principals:
314
+ account = int(account)
315
+ seen.add(account)
316
+ username = accounts.get(account)
317
+ last_active = max(int(last_seen or 0), active.get(username, 0), int(booked.get(account) or 0)) or None
318
+ if username is None:
319
+ reason = "dangling"
320
+ elif issuer not in partners:
321
+ reason = "issuer gone"
322
+ elif stale_days is not None and not ahead.get(username) and (last_active or 0) < now - stale_days * 86400:
323
+ reason = "stale"
324
+ else:
325
+ continue
326
+ add(account, username, issuer, last_active, reason, principal_id)
327
+ for account, username in accounts.items():
328
+ if int(account) not in seen:
329
+ add(int(account), username, homes.get(account), None, "dangling")
330
+ return out
@@ -59,8 +59,9 @@ reach your users as soon as you are in (`cluster remove` leaves such a
59
59
  cluster). Joining Global shares none of your hardware on its own.
60
60
 
61
61
  Once an hour it sends Global a short note: this deployment's name and
62
- ID, version, uptime, how many accounts, what devices it has, how many
63
- reservations are active, and your LAN address. Nothing else leaves your
62
+ ID, version, uptime, how many accounts (and how many visit from partner
63
+ labs), what devices it has, how many reservations are active, and your
64
+ LAN address. Nothing else leaves your
64
65
  server -- no credentials, no user or reservation details -- and the
65
66
  connection only goes outward.
66
67
 
@@ -701,6 +702,7 @@ STATUS_FIELDS = frozenset(
701
702
  "server_version",
702
703
  "uptime_s",
703
704
  "users",
705
+ "proxies",
704
706
  "devices",
705
707
  "active_reservations",
706
708
  "lan_address",
@@ -786,7 +788,9 @@ def status_payload() -> dict:
786
788
  payload = {
787
789
  "server_version": server_version(),
788
790
  "uptime_s": _uptime_seconds(),
789
- "users": _count(reservations, "SELECT COUNT(*) FROM Accounts"),
791
+ # A partner's user visiting here is counted at their own lab, not again here.
792
+ "users": _count(reservations, "SELECT COUNT(*) FROM Accounts WHERE auth_type != 'federated'"),
793
+ "proxies": _count(reservations, "SELECT COUNT(*) FROM Accounts WHERE auth_type = 'federated'"),
790
794
  "devices": device_summary(),
791
795
  "active_reservations": _count(
792
796
  reservations,
@@ -810,7 +814,14 @@ def send_liveness() -> None:
810
814
  def send_status() -> None:
811
815
  from datetime import datetime, timezone
812
816
 
813
- call("POST", REPORT_PATH, {"kind": "status", "status": status_payload()})
817
+ payload = status_payload()
818
+ try:
819
+ call("POST", REPORT_PATH, {"kind": "status", "status": payload})
820
+ except GlobalError as exc:
821
+ if exc.code != "invalid_status":
822
+ raise
823
+ # A Global older than `proxies` takes the note as it was before.
824
+ call("POST", REPORT_PATH, {"kind": "status", "status": {k: v for k, v in payload.items() if k != "proxies"}})
814
825
  update(GLOBAL_LAST_REPORT_AT=datetime.now(timezone.utc).isoformat())
815
826
 
816
827
 
@@ -1245,6 +1245,54 @@ def cmd_users_manage(argv): set_acc()
1245
1245
  def cmd_users_perms(argv): perms.print_perms()
1246
1246
  def cmd_users_purge(argv): reservation_handler.remove_all_users()
1247
1247
  def cmd_reservations_list(argv): reservation_handler.print_all_reservations()
1248
+
1249
+
1250
+ def cmd_users_prune_proxies(argv):
1251
+ """Dry run unless -y: proxies whose home left, half-deleted ones, and with
1252
+ --stale DAYS those unseen that long with nothing booked."""
1253
+ import sqlite3
1254
+ from contextlib import closing
1255
+
1256
+ from ..common.utils.panel import print_table
1257
+ from .federation import cluster
1258
+ from .federation.principals import orphan_proxies
1259
+ from .user_group_handler import user_group_handler
1260
+
1261
+ stale = None
1262
+ if "--stale" in argv:
1263
+ try:
1264
+ stale = int(argv[argv.index("--stale") + 1])
1265
+ except (IndexError, ValueError):
1266
+ printf("Usage: users prune-proxies [--stale DAYS] [-y]", Sty.RED)
1267
+ return 2
1268
+ orphans = orphan_proxies(reservation_handler.filepath, user_group_handler.filepath, stale_days=stale)
1269
+ names = {}
1270
+ for item in cluster.load().values():
1271
+ for peer, party in cluster._parties(item):
1272
+ names.setdefault(peer, party.get("display_name"))
1273
+ rows = [{"username": o["username"] or "(no account)", "home": names.get(o["issuer"]) or o["issuer"] or "?",
1274
+ "last_active": datetime.fromtimestamp(o["last_active"]).strftime("%Y-%m-%d") if o["last_active"] else "",
1275
+ "reservations": o["reservations"], "reason": o["reason"]} for o in orphans]
1276
+ print_table("Orphaned federated proxies", rows, ["username", "home", "last_active", "reservations", "reason"],
1277
+ empty="No orphaned proxies.")
1278
+ if not orphans:
1279
+ return 0
1280
+ if any(o["reason"] == "stale" for o in orphans):
1281
+ printf("A 'stale' visitor may still be real: removing one ends their access here until "
1282
+ "their home lab enrols them again.", Sty.YELLOW)
1283
+ if not ("-y" in argv or "--yes" in argv):
1284
+ printf(f"{len(orphans)} orphaned proxies. Nothing removed; run `users prune-proxies"
1285
+ f"{f' --stale {stale}' if stale is not None else ''} -y` to remove them.", Sty.GRAY)
1286
+ return 0
1287
+ for o in orphans:
1288
+ if o["username"]:
1289
+ reservation_handler.remove_user(username=o["username"])
1290
+ else:
1291
+ with closing(sqlite3.connect(user_group_handler.filepath, timeout=10.0)) as conn, conn:
1292
+ conn.execute("DELETE FROM FederatedPrincipals WHERE id=?", (o["principal_id"],))
1293
+ cluster.forget_account(o["local_account_id"])
1294
+ printf(f"Removed {len(orphans)} orphaned proxies.", Sty.GRAY)
1295
+ return 0
1248
1296
  def cmd_reservations_purge(argv): reservation_handler.remove_all_reservations()
1249
1297
  def cmd_db_purge(argv): reservation_handler.rm_db()
1250
1298
  def cmd_stats_purge(argv):