remoteRF-server 1.1.1__tar.gz → 1.1.2__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 (166) hide show
  1. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/PKG-INFO +1 -1
  2. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/pyproject.toml +1 -1
  3. {remoterf_server-1.1.1/src/remoteRF_server/drivers/adalm_pluto → remoterf_server-1.1.2/src/remoteRF_server/common/idl}/pluto_schema.py +3 -0
  4. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/api_token.py +19 -1
  5. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +1 -2
  6. {remoterf_server-1.1.1/src/remoteRF_server/common/idl → remoterf_server-1.1.2/src/remoteRF_server/drivers/adalm_pluto}/pluto_schema.py +3 -0
  7. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/adalm_pluto/poe.py +62 -16
  8. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/commands.py +1 -5
  9. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/device_manager.py +12 -2
  10. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/direct_path.py +69 -13
  11. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/global_onboarding.py +45 -22
  12. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/grpc_server.py +1 -1
  13. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/reservation.py +98 -16
  14. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/rpc_manager.py +14 -4
  15. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/PKG-INFO +1 -1
  16. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/SOURCES.txt +2 -0
  17. remoterf_server-1.1.2/tests/test_account_hardening.py +134 -0
  18. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_cluster.py +1 -1
  19. remoterf_server-1.1.2/tests/test_direct_path_server.py +174 -0
  20. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_global_accounts_migration.py +3 -1
  21. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_global_onboarding.py +98 -30
  22. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_pluto_compat.py +1 -1
  23. remoterf_server-1.1.2/tests/test_pluto_poe.py +209 -0
  24. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_rpc_manager_calln.py +11 -3
  25. remoterf_server-1.1.1/tests/test_pluto_poe.py +0 -100
  26. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/LICENSE +0 -0
  27. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/README.md +0 -0
  28. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/setup.cfg +0 -0
  29. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/__init__.py +0 -0
  30. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/__init__.py +0 -0
  31. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/__init__.py +0 -0
  32. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/deployment_capabilities.proto +0 -0
  33. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2.py +0 -0
  34. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  35. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/federation.proto +0 -0
  36. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/federation_pb2.py +0 -0
  37. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/federation_pb2_grpc.py +0 -0
  38. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc.proto +0 -0
  39. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc_host.proto +0 -0
  40. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
  41. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
  42. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
  43. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
  44. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/grpc/v2_codec.py +0 -0
  45. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/idl/__init__.py +0 -0
  46. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/idl/device_schema.py +0 -0
  47. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/idl/pluto_mimo_schema.py +0 -0
  48. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/idl/schema.py +0 -0
  49. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/idl/v2.py +0 -0
  50. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/__init__.py +0 -0
  51. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
  52. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/db_connection.py +0 -0
  53. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/db_location.py +0 -0
  54. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/list_string.py +0 -0
  55. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/panel.py +0 -0
  56. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/common/utils/process_arg.py +0 -0
  57. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/__init__.py +0 -0
  58. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  59. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/adalm_pluto/pluto_mimo_schema.py +0 -0
  60. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/hackrf/__init__.py +0 -0
  61. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/hackrf/hackrf_schema.py +0 -0
  62. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/rtl_sdr/__init__.py +0 -0
  63. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_schema.py +0 -0
  64. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/ti_mmwave/__init__.py +0 -0
  65. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/ti_mmwave/ti_mmwave_schema.py +0 -0
  66. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp/__init__.py +0 -0
  67. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp/policy.py +0 -0
  68. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp/profiles.py +0 -0
  69. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp/uhd_manifest.py +0 -0
  70. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp/usrp_schema.py +0 -0
  71. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp_phaser/__init__.py +0 -0
  72. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/drivers/usrp_phaser/usrp_phaser_schema.py +0 -0
  73. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/host/__init__.py +0 -0
  74. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/host/host_auth_token.py +0 -0
  75. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/host/host_directory_store.py +0 -0
  76. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
  77. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/__init__.py +0 -0
  78. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/acc_perms.py +0 -0
  79. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/cert_provider.py +0 -0
  80. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/console.py +0 -0
  81. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/control.py +0 -0
  82. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/deployment/__init__.py +0 -0
  83. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/deployment/capabilities.py +0 -0
  84. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/dynamic_v2.py +0 -0
  85. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/dynamic_v2_bridge.py +0 -0
  86. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/__init__.py +0 -0
  87. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/admin.py +0 -0
  88. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/cli.py +0 -0
  89. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/cluster.py +0 -0
  90. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/contracts.py +0 -0
  91. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/destination.py +0 -0
  92. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/devices.py +0 -0
  93. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/enrollment.py +0 -0
  94. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/home.py +0 -0
  95. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/home_devices.py +0 -0
  96. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/identity.py +0 -0
  97. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/legacy_state.py +0 -0
  98. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/migrations.py +0 -0
  99. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/peers.py +0 -0
  100. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/permissions.py +0 -0
  101. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/principals.py +0 -0
  102. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/runtime.py +0 -0
  103. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/federation/trust.py +0 -0
  104. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/host_dynamic_v2.py +0 -0
  105. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/user_group_cli.py +0 -0
  106. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/user_group_handler.py +0 -0
  107. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/server/v2_errors.py +0 -0
  108. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/serverrf_cli.py +0 -0
  109. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/tools/__init__.py +0 -0
  110. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/tools/gen_certs.py +0 -0
  111. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/tools/gist_status.py +0 -0
  112. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/tools/gist_status_testing.py +0 -0
  113. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server/tools/introspect_uhd.py +0 -0
  114. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
  115. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
  116. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/requires.txt +0 -0
  117. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/src/remoteRF_server.egg-info/top_level.txt +0 -0
  118. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_bind_host.py +0 -0
  119. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_cert_provider.py +0 -0
  120. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_client_sessions.py +0 -0
  121. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_commands.py +0 -0
  122. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_console.py +0 -0
  123. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_default_rtl_sdr_schema.py +0 -0
  124. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_default_usrp_schema.py +0 -0
  125. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_deployment_capabilities.py +0 -0
  126. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_direct_fixed_port.py +0 -0
  127. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_dynamic_v2.py +0 -0
  128. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_federated_direct_path.py +0 -0
  129. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_federated_dynamic_v2.py +0 -0
  130. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_federation_edges.py +0 -0
  131. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_first_run.py +0 -0
  132. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_d_contracts.py +0 -0
  133. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_e_federation.py +0 -0
  134. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_e_proto.py +0 -0
  135. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_f_enrollment.py +0 -0
  136. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_g1_logging.py +0 -0
  137. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_g_permissions.py +0 -0
  138. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_h_devices.py +0 -0
  139. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gate_i_direct_path.py +0 -0
  140. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_gen_certs.py +0 -0
  141. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_global_v1_disposal.py +0 -0
  142. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_hackrf_schema.py +0 -0
  143. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_home_device_plane.py +0 -0
  144. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_host_directory_store.py +0 -0
  145. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_host_dynamic_v2.py +0 -0
  146. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_idl_schema.py +0 -0
  147. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_panel.py +0 -0
  148. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_peer_transport.py +0 -0
  149. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_process_arg.py +0 -0
  150. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_reservation_handoff.py +0 -0
  151. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_reservation_policy_b.py +0 -0
  152. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_reservation_timestamps.py +0 -0
  153. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_rtl_sdr_schema.py +0 -0
  154. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_soak_federation.py +0 -0
  155. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_status_push.py +0 -0
  156. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_ti_mmwave_hardware.py +0 -0
  157. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_ti_mmwave_schema.py +0 -0
  158. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_user_group_cli.py +0 -0
  159. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_b205mini_uhd410_hardware.py +0 -0
  160. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_client_server_hardware.py +0 -0
  161. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_client_server_localhost.py +0 -0
  162. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_n210_client_server_hardware.py +0 -0
  163. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_profiles.py +0 -0
  164. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_remoterf_rx_hardware.py +0 -0
  165. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_usrp_uhd410_hardware.py +0 -0
  166. {remoterf_server-1.1.1 → remoterf_server-1.1.2}/tests/test_wipe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.1.1
3
+ Version: 1.1.2
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoteRF-server"
7
- version = "1.1.1"
7
+ version = "1.1.2"
8
8
  description = "RemoteRF server-side control package"
9
9
  license = { text = "GPL-3.0-or-later" }
10
10
  requires-python = ">=3.10"
@@ -202,6 +202,9 @@ class PlutoSchema(DeviceSchema):
202
202
 
203
203
  @idl_expose(kind="set")
204
204
  def set_rx_buffer_size(self, value):
205
+ # pyadi reads rx_buffer_size only when it (re)creates the RX buffer.
206
+ if value != self.device.rx_buffer_size:
207
+ self.device.rx_destroy_buffer()
205
208
  self.device.rx_buffer_size = value
206
209
 
207
210
  @idl_expose(kind="call")
@@ -15,6 +15,7 @@
15
15
 
16
16
  import os
17
17
  import hashlib
18
+ import hmac
18
19
  import base64
19
20
  import secrets
20
21
  from dotenv import load_dotenv, find_dotenv
@@ -33,4 +34,21 @@ def validate_token(salt, hash, token) -> bool:
33
34
  def hash_token(token: str) -> tuple[str, str]:
34
35
  salt = os.urandom(16).hex()
35
36
  hashed = hashlib.sha256(bytes.fromhex(salt) + token.encode()).hexdigest()
36
- return salt, hashed
37
+ return salt, hashed
38
+
39
+ # Passwords get a real KDF; tokens stay on hash_token (random, high entropy).
40
+ PASSWORD_HASH_PREFIX = "pbkdf2$"
41
+ _PBKDF2_ROUNDS = 210_000
42
+
43
+ def hash_password(password: str) -> tuple[str, str]:
44
+ salt = os.urandom(16).hex()
45
+ digest = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), _PBKDF2_ROUNDS)
46
+ return salt, f"{PASSWORD_HASH_PREFIX}{_PBKDF2_ROUNDS}${digest.hex()}"
47
+
48
+ def verify_password(salt, hash, password) -> tuple[bool, bool]:
49
+ """Returns (matches, needs_rehash). Accepts legacy hash_token() hashes."""
50
+ if not hash.startswith(PASSWORD_HASH_PREFIX):
51
+ return validate_token(salt, hash, password), True
52
+ _, rounds, digest = hash.split('$', 2)
53
+ computed = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), int(rounds))
54
+ return hmac.compare_digest(computed.hex(), digest), False
@@ -90,8 +90,7 @@ class PlutoServer:
90
90
  if result is not None:
91
91
  return {function_name: map_arg(result)}
92
92
  else:
93
- # If there's no return value, return some placeholder
94
- return {function_name: map_arg('None')}
93
+ return {function_name: map_arg(None)}
95
94
 
96
95
  @staticmethod
97
96
  def get(*, function_name, token):
@@ -202,6 +202,9 @@ class PlutoSchema(DeviceSchema):
202
202
 
203
203
  @idl_expose(kind="set")
204
204
  def set_rx_buffer_size(self, value):
205
+ # pyadi reads rx_buffer_size only when it (re)creates the RX buffer.
206
+ if value != self.device.rx_buffer_size:
207
+ self.device.rx_destroy_buffer()
205
208
  self.device.rx_buffer_size = value
206
209
 
207
210
  @idl_expose(kind="call")
@@ -26,9 +26,11 @@ import threading
26
26
  import time
27
27
 
28
28
  from remoteRF_server.common.utils import get_remoterf_root
29
+ from remoteRF_server.common.utils.ansi_codes import Sty, printf
29
30
 
30
31
  IIOD_PORT = 30431
31
- BOOT_TIMEOUT_S = 120
32
+ BOOT_TIMEOUT_S = 45
33
+ LINK_CHECK_S = 30 # no IIOD by then: ask the switch whether the port even has a link
32
34
  OFF_S = 3 # long enough for the Pluto to lose power before it is switched back on
33
35
 
34
36
 
@@ -90,6 +92,19 @@ def set_poe(*, switch, user, password, port, on):
90
92
  ros.close()
91
93
 
92
94
 
95
+ def port_running(*, switch, user, password, port):
96
+ """Whether the switch port has an Ethernet link (RouterOS 'running')."""
97
+ ros = RouterOS(switch, user, password)
98
+ try:
99
+ replies = ros.cmd("/interface/ethernet/print", f"?name={port}", "=.proplist=name,running")
100
+ finally:
101
+ ros.close()
102
+ for sentence in replies:
103
+ if sentence[0] == "!re":
104
+ return "=running=true" in sentence
105
+ raise RuntimeError(f"switch has no interface {port}")
106
+
107
+
93
108
  def _iiod_up(ip):
94
109
  try:
95
110
  socket.create_connection((ip, IIOD_PORT), timeout=1).close()
@@ -110,6 +125,7 @@ class PoePluto:
110
125
  _serial=str(serial or "").strip(),
111
126
  _switch={"switch": switch, "user": user, "password": password},
112
127
  _pluto=None,
128
+ fault=None, # message of the last failed boot; None once a boot succeeds
113
129
  _lock=threading.RLock(),
114
130
  )
115
131
 
@@ -119,28 +135,58 @@ class PoePluto:
119
135
  def _poe(self, on):
120
136
  set_poe(port=self._port, on=on, **self._switch)
121
137
 
138
+ @property
139
+ def power_state(self):
140
+ if self.fault:
141
+ return "fault"
142
+ return "on" if self._pluto is not None else "standby"
143
+
122
144
  def power_up(self):
123
145
  with self._lock:
124
146
  if self._pluto is not None:
125
147
  return
126
- self._poe(True)
127
- deadline = time.monotonic() + BOOT_TIMEOUT_S
128
- while not _iiod_up(self._ip):
129
- if time.monotonic() > deadline:
130
- self._poe(False)
131
- raise RuntimeError(f"PoE Pluto {self.uri} did not boot within {BOOT_TIMEOUT_S} s on {self._port}")
132
- time.sleep(1)
133
- import adi
134
-
135
- pluto = adi.Pluto(self.uri)
136
- found = str(pluto._ctx.attrs.get("hw_serial", "")).strip()
137
- if self._serial and found.lower() != self._serial.lower():
138
- del pluto
148
+ try:
149
+ self._boot()
150
+ except Exception as e:
151
+ vars(self)["fault"] = str(e)
152
+ printf(f"{time.strftime('%H:%M:%S')} {e}", Sty.GRAY)
153
+ raise
154
+ vars(self)["fault"] = None
155
+
156
+ def _boot(self):
157
+ self._poe(True)
158
+ started = time.monotonic()
159
+ link_checked = False
160
+ while not _iiod_up(self._ip):
161
+ waited = time.monotonic() - started
162
+ if waited > BOOT_TIMEOUT_S:
139
163
  self._poe(False)
140
- raise RuntimeError(f"{self.uri} on {self._port} is Pluto {found}, expected {self._serial}")
141
- vars(self)["_pluto"] = pluto
164
+ raise RuntimeError(f"PoE Pluto {self.uri} did not boot within {BOOT_TIMEOUT_S} s on {self._port}")
165
+ if waited > LINK_CHECK_S and not link_checked:
166
+ link_checked = True
167
+ try:
168
+ running = port_running(port=self._port, **self._switch)
169
+ except Exception:
170
+ running = True # switch unreachable: keep waiting for IIOD
171
+ if not running:
172
+ self._poe(False)
173
+ raise RuntimeError(
174
+ f"Pluto {self.uri}: PoE on {self._port} but no Ethernet link after {LINK_CHECK_S} s (cable/port/board fault)"
175
+ )
176
+ time.sleep(1)
177
+ import adi
178
+
179
+ pluto = adi.Pluto(self.uri)
180
+ found = str(pluto._ctx.attrs.get("hw_serial", "")).strip()
181
+ if self._serial and found.lower() != self._serial.lower():
182
+ del pluto
183
+ self._poe(False)
184
+ raise RuntimeError(f"{self.uri} on {self._port} is Pluto {found}, expected {self._serial}")
185
+ vars(self)["_pluto"] = pluto
142
186
 
143
187
  def power_down(self):
188
+ """Cut power. The Pluto boots with default settings: sample rate, LOs,
189
+ gains and rx_buffer_size are all reset by the next power_up()."""
144
190
  with self._lock:
145
191
  # Drop the handle while the Pluto can still answer its buffer teardown.
146
192
  vars(self)["_pluto"] = None
@@ -106,7 +106,7 @@ def _table() -> list[Command]:
106
106
  # ---- devices ----
107
107
  Command(("devices list",), "Configured devices", _shell("cmd_devices_list"), "Devices"),
108
108
  Command(("devices status",), "Live device online/last_seen/route", _daemon("cmd_devices_status"), "Devices", where=D),
109
- Command(("devices add",), "Add a device (asks type, name, serial)", _shell("cmd_devices_add"), "Devices", after="reload_devices"),
109
+ Command(("devices add",), "Add a device (asks type, name, serial)", _shell("cmd_devices_add"), "Devices", after="reload_devices", hidden=True),
110
110
  Command(("devices remove",), "Remove a device by id", _shell("cmd_devices_remove"), "Devices", after="reload_devices"),
111
111
  Command(("devices rename",), "Rename a device: devices rename <id> <name>", _shell("cmd_devices_rename"), "Devices", after="reload_devices"),
112
112
  Command(("devices wipe",), "Remove ALL devices [-y]", _shell("cmd_devices_wipe"), "Devices", after="reload_devices"),
@@ -264,10 +264,6 @@ def cmd_help(argv: list[str]) -> int:
264
264
  return 2
265
265
  rows = [c for c in _visible(_IN_DAEMON) if c.section == section]
266
266
  lines = [_SECTION_BLURB.get(section, ""), ""] + _columns([(prefix + c.names[0], c.help) for c in rows])
267
- if section == "Devices":
268
- lines += ["", "Types for `devices add`:",
269
- " pluto, pluto-mimo, rtl-sdr, hackrf, ti-mmwave,",
270
- " usrp-2901, usrp-b205-mini, usrp-n210, usrp-profile"]
271
267
  print_panel(section, lines, literal=True)
272
268
  return 0
273
269
  lines: list[str] = []
@@ -77,6 +77,10 @@ def _is_connected(st: _DeviceState) -> bool:
77
77
  # host
78
78
  return bool(st.online)
79
79
 
80
+ def _poe_fault(st: _DeviceState):
81
+ """Last boot failure of a local PoE Pluto (None otherwise; see drivers/adalm_pluto/poe.py)."""
82
+ return getattr(getattr(st.dev, "device", None), "fault", None)
83
+
80
84
  # Host device pulling/grabbing
81
85
 
82
86
  def _get_host_registry():
@@ -187,6 +191,12 @@ def _local_devices_str_snapshot() -> Dict[int, str]:
187
191
  name = (st.name or f"device-{gid}").strip()
188
192
 
189
193
  status = "online" if _is_connected(st) else "offline"
194
+ fault = _poe_fault(st)
195
+ power = getattr(getattr(st.dev, "device", None), "power_state", None)
196
+ if fault:
197
+ status = f"offline: {fault}"
198
+ elif power:
199
+ status = f"{status}, {power}"
190
200
  if st.salt or st.hsh:
191
201
  status = f"{status}, reserved"
192
202
 
@@ -728,7 +738,7 @@ def describe_devices(ids) -> list[dict]:
728
738
  if st is None:
729
739
  continue
730
740
  out.append({"id": int(gid), "name": (st.name or f"device-{gid}").strip(),
731
- "type": st.dtype or "", "online": _is_connected(st)})
741
+ "type": st.dtype or "", "online": _is_connected(st) and not _poe_fault(st)})
732
742
  return out
733
743
 
734
744
 
@@ -1187,7 +1197,7 @@ def device_inventory_snapshot() -> Dict[int, Dict[str, object]]:
1187
1197
  gid: {
1188
1198
  "name": st.name or f"device {gid}",
1189
1199
  "device_type": st.dtype or None,
1190
- "online": _is_connected(st),
1200
+ "online": _is_connected(st) and not _poe_fault(st),
1191
1201
  "origin": st.origin,
1192
1202
  }
1193
1203
  for gid, st in _devices.items()
@@ -45,6 +45,7 @@ import os
45
45
  import secrets
46
46
  import socket
47
47
  import threading
48
+ import time
48
49
  from pathlib import Path
49
50
  from typing import Optional
50
51
 
@@ -125,6 +126,20 @@ ALPN = "remoterf-direct/1"
125
126
  MAX_FRAME = 100 * 1024 * 1024 # the gRPC message ceiling
126
127
  IDLE_SECONDS = 600
127
128
  CONNECT_SECONDS = 30 # ICE checks, then the QUIC handshake; a silent client is dropped
129
+ # How long the punch waits for the client's first check when the answer also
130
+ # offered the fixed port. A 2.1.0 client dials that port for 2 s before its
131
+ # first check; a check of ours reaching its NAT before then leaves a stale
132
+ # entry there that shadows the port its own checks are then mapped to (Linux
133
+ # NAT picks another), and the punch can never complete. A check that arrives
134
+ # sooner ends the wait; a client that never reaches us gets ours after it.
135
+ # 3.0 = the 2 s dial plus ~1 s for the relay round trip that carries the
136
+ # answer; a shorter hold reproduces the shadow on a slow relay.
137
+ HOLD_SECONDS = 3.0
138
+ # A client that punches at once ("race" in its offer) sends its first check
139
+ # as soon as the answer reaches it, but that check is dropped at a border
140
+ # that has not seen ours yet, so it cannot end the hold: ours must simply
141
+ # leave after its first check has, and before its retransmit at 0.5 s.
142
+ RACE_HOLD_SECONDS = 0.4
128
143
  MAX_PATHS_PER_SESSION = 8 # processes of one login; the oldest path makes room
129
144
  DEFAULT_STUN = "stun.l.google.com:19302"
130
145
  STUN_SECONDS = 1.5 # a STUN server answers in milliseconds or not at all
@@ -145,6 +160,23 @@ _listener = None
145
160
  _listener_port = 0
146
161
 
147
162
 
163
+ def say(message: str) -> None:
164
+ """A console line about a path, stamped: paths fail minutes apart."""
165
+ printf(f"{time.strftime('%H:%M:%S')} {message}", Sty.GRAY)
166
+
167
+
168
+ def describe_failure(exc: BaseException, stage: str) -> str:
169
+ """The exception class and a message that says what did not happen."""
170
+ if isinstance(exc, asyncio.TimeoutError):
171
+ return f"TimeoutError: {stage} did not complete within {CONNECT_SECONDS} s"
172
+ return f"{type(exc).__name__}: {exc}"
173
+
174
+
175
+ def describe_remotes(ice: Connection) -> str:
176
+ """The candidates the client offered, host:port/type each."""
177
+ return " ".join(f"{c.host}:{c.port}/{c.type}" for c in ice.remote_candidates) or "none"
178
+
179
+
148
180
  def frame(payload: bytes) -> bytes:
149
181
  return len(payload).to_bytes(4, "big") + payload
150
182
 
@@ -187,8 +219,15 @@ class Ice(Connection):
187
219
  server, and aioice would hold gathering for its whole 5 s timeout on each
188
220
  one, on every offer. One interface and a short STUN wait instead; a
189
221
  machine without a default route gathers the way aioice does.
222
+
223
+ ``heard`` is set by the peer's first check, which may arrive before
224
+ connect() (aioice holds it as an early check until then).
190
225
  """
191
226
 
227
+ def __init__(self, *args, **kwargs):
228
+ super().__init__(*args, **kwargs)
229
+ self.heard = asyncio.Event()
230
+
192
231
  async def gather_candidates(self) -> None:
193
232
  address = default_route_address()
194
233
  if address is None or self._local_candidates_start:
@@ -199,6 +238,10 @@ class Ice(Connection):
199
238
  )
200
239
  self._local_candidates_end = True
201
240
 
241
+ def request_received(self, *args) -> None:
242
+ super().request_received(*args)
243
+ self.heard.set()
244
+
202
245
 
203
246
  def peer_address(ice: Connection) -> tuple[str, int]:
204
247
  """The remote end of the nominated pair (aioice keeps it private)."""
@@ -299,11 +342,12 @@ class _Protocol(QuicConnectionProtocol):
299
342
  class _Path:
300
343
  """One client session's punched pair and the QUIC connection over it."""
301
344
 
302
- def __init__(self, key: str, user: str, ice: Connection, bound: Optional[dict] = None):
345
+ def __init__(self, key: str, user: str, ice: Connection, bound: Optional[dict] = None, hold: float = 0.0):
303
346
  self.key = key
304
347
  self.user = user
305
348
  self.ice = ice
306
349
  self.bound = bound
350
+ self.hold = hold # seconds to wait for the client's first check (a fixed port was offered too)
307
351
  self.peer: Optional[tuple[str, int]] = None
308
352
  self.calls = 0
309
353
  self.task: Optional[asyncio.Task] = None
@@ -312,24 +356,31 @@ class _Path:
312
356
 
313
357
  async def run(self) -> None:
314
358
  protocol = None
359
+ stage = "ICE"
315
360
  try:
361
+ if self.hold:
362
+ with contextlib.suppress(asyncio.TimeoutError):
363
+ await asyncio.wait_for(self.ice.heard.wait(), self.hold)
316
364
  await asyncio.wait_for(self.ice.connect(), CONNECT_SECONDS)
317
365
  self.peer = peer_address(self.ice)
366
+ stage = "the QUIC Initial"
318
367
  protocol = await asyncio.wait_for(self._accept(), CONNECT_SECONDS)
319
368
  pump = asyncio.ensure_future(self._pump(protocol))
320
369
  closed = asyncio.ensure_future(protocol.wait_closed())
321
370
  try:
371
+ stage = "the QUIC handshake"
322
372
  try:
323
373
  await asyncio.wait_for(protocol.wait_connected(), CONNECT_SECONDS)
324
374
  except ConnectionError:
325
375
  raise ConnectionError(f"QUIC handshake failed: {protocol.terminated}") from None
326
376
  where = "offsite" if is_offsite(self.peer[0]) else "local"
327
- printf(f"Direct path open: {self.user} from {self.peer[0]}:{self.peer[1]} ({where})", Sty.GRAY)
377
+ say(f"Direct path open: {self.user} from {self.peer[0]}:{self.peer[1]} ({where})")
328
378
  await asyncio.wait({pump, closed}, return_when=asyncio.FIRST_COMPLETED)
329
379
  finally:
330
380
  await cancel_tasks((pump, closed))
331
381
  except Exception as exc: # noqa: BLE001 - a failed punch is expected on some networks
332
- printf(f"Direct path failed: {self.user}: {type(exc).__name__}: {exc}", Sty.GRAY)
382
+ held = f"; held {self.hold:g} s" if self.hold else ""
383
+ say(f"Direct path failed: {self.user}: {describe_failure(exc, stage)}; offered {describe_remotes(self.ice)}{held}")
333
384
  finally:
334
385
  if protocol is not None:
335
386
  protocol.close()
@@ -339,7 +390,7 @@ class _Path:
339
390
  await self.close()
340
391
  if self.peer is not None:
341
392
  why = f" ({protocol.terminated})" if protocol is not None and protocol.terminated else ""
342
- printf(f"Direct path closed: {self.user} from {self.peer[0]}:{self.peer[1]} after {self.calls} calls{why}", Sty.GRAY)
393
+ say(f"Direct path closed: {self.user} from {self.peer[0]}:{self.peer[1]} after {self.calls} calls{why}")
343
394
 
344
395
  async def _accept(self) -> QuicConnectionProtocol:
345
396
  """The client's Initial packet decides the connection, as aioquic's server does."""
@@ -423,10 +474,10 @@ async def _serve_call(path, reader: asyncio.StreamReader, writer: asyncio.Stream
423
474
  if bad_frame is not None:
424
475
  # A client that cannot frame a request is not ours; its bytes are
425
476
  # still arriving and would only pile up in this stream's buffer.
426
- printf(f"Direct path dropped: {path.user} sent a {bad_frame}", Sty.GRAY)
477
+ say(f"Direct path dropped: {path.user} sent a {bad_frame}")
427
478
  await path.drop()
428
479
  if refused is not None:
429
- printf(f"Direct path refused: {path.user}: {refused}", Sty.GRAY)
480
+ say(f"Direct path refused: {path.user}: {refused}")
430
481
  await path.drop()
431
482
 
432
483
 
@@ -475,7 +526,7 @@ class _Conn(_Protocol):
475
526
  if self.key is not None and _paths.get(self.key) is self:
476
527
  del _paths[self.key]
477
528
  why = f" ({self.terminated})" if self.terminated else ""
478
- printf(f"Direct path closed: {self.user} from {self.peer[0]}:{self.peer[1]} after {self.calls} calls{why}", Sty.GRAY)
529
+ say(f"Direct path closed: {self.user} from {self.peer[0]}:{self.peer[1]} after {self.calls} calls{why}")
479
530
 
480
531
  def _stream(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
481
532
  task = asyncio.ensure_future(self._hello(reader, writer) if self.key is None else _serve_call(self, reader, writer))
@@ -487,7 +538,7 @@ class _Conn(_Protocol):
487
538
  await self._claim(reader, writer)
488
539
  except Exception as exc: # noqa: BLE001 - a hello that cannot be honoured closes the connection
489
540
  logger.warning("direct path fixed-port hello failed: %r", exc)
490
- printf(f"Direct path fixed-port hello failed: {type(exc).__name__}: {exc}", Sty.GRAY)
541
+ say(f"Direct path fixed-port hello failed: {type(exc).__name__}: {exc}")
491
542
  self.close()
492
543
 
493
544
  async def _claim(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -509,7 +560,7 @@ class _Conn(_Protocol):
509
560
  writer.write(frame(json.dumps({"ok": True}).encode()))
510
561
  writer.write_eof()
511
562
  where = "offsite" if is_offsite(self.peer[0]) else "local"
512
- printf(f"Direct path open: {self.user} from {self.peer[0]}:{self.peer[1]} ({where}, fixed port)", Sty.GRAY)
563
+ say(f"Direct path open: {self.user} from {self.peer[0]}:{self.peer[1]} ({where}, fixed port)")
513
564
 
514
565
  async def drop(self) -> None:
515
566
  if self.key is not None and _paths.get(self.key) is self:
@@ -569,13 +620,17 @@ async def _offer(key: str, user: str, remote: dict, bound: Optional[dict] = None
569
620
  except Exception:
570
621
  await close_ice(ice)
571
622
  raise
572
- path = _paths[key] = _Path(key, user, ice, bound)
623
+ fixed = _fixed_port_offer(key, user, bound)
624
+ # A client that races the fixed port against the punch says so ("race")
625
+ # and needs only a short hold; a 2.1.0 offer has no such key.
626
+ hold = 0.0 if not fixed else RACE_HOLD_SECONDS if remote.get("race") else HOLD_SECONDS
627
+ path = _paths[key] = _Path(key, user, ice, bound, hold=hold)
573
628
  path.task = asyncio.ensure_future(path.run())
574
629
  return {
575
630
  "ufrag": ice.local_username,
576
631
  "pwd": ice.local_password,
577
632
  "candidates": [candidate.to_sdp() for candidate in ice.local_candidates],
578
- **_fixed_port_offer(key, user, bound),
633
+ **fixed,
579
634
  }
580
635
 
581
636
 
@@ -612,8 +667,9 @@ def offer(session_key: str, user: str, ice: str) -> dict:
612
667
  answer = asyncio.run_coroutine_threadsafe(
613
668
  _offer(key, user, remote), _get_loop()
614
669
  ).result(20)
615
- except Exception as exc: # noqa: BLE001
616
- return {"ace": map_arg(f"direct path unavailable: {exc}")}
670
+ except Exception as exc: # noqa: BLE001 - the console gets the detail, the client a fixed line
671
+ say(f"Direct path offer failed: {user}: {type(exc).__name__}: {exc}")
672
+ return {"ace": map_arg("direct path unavailable")}
617
673
  return {"ice": map_arg(json.dumps(answer)), "server_name": map_arg(hostname() or "")}
618
674
 
619
675
 
@@ -24,9 +24,8 @@ State lives in ``<config dir>/global.env``:
24
24
  GLOBAL_CONSENT yes | no answered once at first run
25
25
  GLOBAL_NAME requested slug
26
26
  GLOBAL_EMAIL contact address for a human admin
27
- GLOBAL_SHARE_LAN yes | no
28
27
  GLOBAL_STATE unregistered | requested | active | rejected
29
- | suspended | left | expired
28
+ | suspended | left | expired | unknown
30
29
  """
31
30
 
32
31
  from __future__ import annotations
@@ -316,15 +315,11 @@ def configure() -> bool:
316
315
  return False
317
316
  display_name = _prompt_display_name(name)
318
317
  email = _prompt_email()
319
- share = _confirm("Share LAN address?")
320
- if share is None:
321
- return False
322
318
  update(
323
319
  GLOBAL_CONSENT="yes",
324
320
  GLOBAL_NAME=name,
325
321
  GLOBAL_DISPLAY_NAME=display_name,
326
322
  GLOBAL_EMAIL=email,
327
- GLOBAL_SHARE_LAN="yes" if share else "no",
328
323
  GLOBAL_STATE="unregistered",
329
324
  )
330
325
  ensure_identity()
@@ -530,13 +525,6 @@ def lan_endpoint() -> Optional[str]:
530
525
  return f"{host}:{port}" if host else None
531
526
 
532
527
 
533
- def lan_address() -> Optional[str]:
534
- """host:port a client on the LAN should dial, when the operator shares it."""
535
- if load().get("GLOBAL_SHARE_LAN", "yes").lower() != "yes":
536
- return None
537
- return lan_endpoint()
538
-
539
-
540
528
  # -----------------------------
541
529
  # Where a call came from
542
530
  # -----------------------------
@@ -605,7 +593,7 @@ def submit_request() -> dict:
605
593
  "email": kv["GLOBAL_EMAIL"],
606
594
  "public_key": _public_key_b64(),
607
595
  "ssh_public_key": relay_public_key(),
608
- "lan_address": lan_address(),
596
+ "lan_address": lan_endpoint(),
609
597
  "server_version": server_version(),
610
598
  }
611
599
  result = call("POST", REQUEST_PATH, payload)
@@ -621,7 +609,17 @@ def verify_contact(code: str) -> dict:
621
609
 
622
610
 
623
611
  def fetch_me() -> dict:
624
- result = call("GET", ME_PATH)
612
+ try:
613
+ result = call("GET", ME_PATH)
614
+ except GlobalError as exc:
615
+ # Global lost (or never had) this deployment: polling again cannot
616
+ # fix it, only a re-join can. The identity is kept so the re-request
617
+ # is keyed by the same issuer UUID.
618
+ if exc.code == "unknown_deployment" and state() != "unknown":
619
+ update(GLOBAL_STATE="unknown")
620
+ _warn(" RemoteRF Global has no record of this deployment; "
621
+ "the relay stops. Run `serverrf global join` to re-register.")
622
+ raise
625
623
  _apply_me(result)
626
624
  return result
627
625
 
@@ -783,7 +781,7 @@ def status_payload() -> dict:
783
781
  "SELECT COUNT(*) FROM Reservations WHERE start_time <= ? AND end_time > ?",
784
782
  (now, now),
785
783
  ),
786
- "lan_address": lan_address(),
784
+ "lan_address": lan_endpoint(),
787
785
  "cert_sha256": cert_sha256(),
788
786
  }
789
787
  assert set(payload) == STATUS_FIELDS # the consent text is the contract
@@ -791,7 +789,10 @@ def status_payload() -> dict:
791
789
 
792
790
 
793
791
  def send_liveness() -> None:
792
+ from datetime import datetime, timezone
793
+
794
794
  call("POST", REPORT_PATH, {"kind": "liveness"})
795
+ update(GLOBAL_LAST_SEEN_AT=datetime.now(timezone.utc).isoformat())
795
796
 
796
797
 
797
798
  def send_status() -> None:
@@ -943,10 +944,11 @@ POLL_ACTIVE_SECONDS = 3600
943
944
  LIVENESS_SECONDS = 300
944
945
  STATUS_SECONDS = 3600
945
946
  TICK_SECONDS = 60
947
+ POLL_BACKOFF_MAX_SECONDS = 900
946
948
 
947
949
  # States the agent will never leave on its own: the operator or a Global admin
948
950
  # has to act first, so the loop stops instead of retrying forever.
949
- TERMINAL_STATES = {"left", "rejected", "expired"}
951
+ TERMINAL_STATES = {"left", "rejected", "expired", "unknown"}
950
952
 
951
953
 
952
954
  def _tick(last: dict, tunnel=None) -> None:
@@ -961,7 +963,15 @@ def _tick(last: dict, tunnel=None) -> None:
961
963
  return
962
964
  interval = POLL_ACTIVE_SECONDS if current == "active" else POLL_PENDING_SECONDS
963
965
  if now - last.get("poll", -interval) >= interval:
964
- fetch_me()
966
+ try:
967
+ fetch_me()
968
+ except Exception:
969
+ # Stamp the poll so the next attempt waits out the backoff.
970
+ backoff = min(2 * last.get("backoff", TICK_SECONDS), POLL_BACKOFF_MAX_SECONDS)
971
+ last["backoff"] = backoff
972
+ last["poll"] = now - interval + backoff
973
+ raise
974
+ last.pop("backoff", None)
965
975
  last["poll"] = now
966
976
  if tunnel is not None:
967
977
  tunnel.supervise(now)
@@ -972,7 +982,12 @@ def _tick(last: dict, tunnel=None) -> None:
972
982
  ("status", STATUS_SECONDS, send_status),
973
983
  ):
974
984
  if now - last.get(key, -interval) >= interval:
975
- send()
985
+ try:
986
+ send()
987
+ except GlobalError as exc:
988
+ if exc.code == "unknown_deployment":
989
+ last.pop("poll", None) # confirm with /me next tick, not in an hour
990
+ raise
976
991
  last[key] = now
977
992
  _cluster_tick()
978
993
 
@@ -1016,7 +1031,10 @@ def run_agent(stop=None, tunnel=None) -> None:
1016
1031
  return
1017
1032
  _tick(last, tunnel)
1018
1033
  except Exception as exc: # noqa: BLE001 - never take the server down
1019
- logger.warning("global agent tick failed: %s", exc)
1034
+ logger.warning(
1035
+ "global agent tick failed: %s (%s)",
1036
+ exc, getattr(exc, "code", type(exc).__name__),
1037
+ )
1020
1038
  if stop is not None:
1021
1039
  if stop.wait(TICK_SECONDS):
1022
1040
  tunnel.stop()
@@ -1088,6 +1106,7 @@ _STATE_SUMMARY = {
1088
1106
  "suspended": "suspended by a RemoteRF admin",
1089
1107
  "left": "left RemoteRF Global",
1090
1108
  "expired": "request expired",
1109
+ "unknown": "RemoteRF Global has no record of this deployment -- run: serverrf global join",
1091
1110
  }
1092
1111
 
1093
1112
 
@@ -1118,10 +1137,12 @@ def cmd_status(refresh: bool = True) -> int:
1118
1137
 
1119
1138
  # Ask Global rather than reporting a stale local answer: the operator runs
1120
1139
  # this precisely because they are waiting to hear about their request.
1121
- unreachable = None
1140
+ unreachable = refused = None
1122
1141
  if refresh and state() not in TERMINAL_STATES:
1123
1142
  try:
1124
1143
  fetch_me()
1144
+ except GlobalError as exc:
1145
+ refused = exc.code
1125
1146
  except Exception as exc: # noqa: BLE001 - offline is not an error here
1126
1147
  unreachable = str(exc)
1127
1148
  kv = load()
@@ -1136,9 +1157,9 @@ def cmd_status(refresh: bool = True) -> int:
1136
1157
  ("display", kv.get("GLOBAL_DISPLAY_NAME", "-")),
1137
1158
  ("hostname", hostname() or "-"),
1138
1159
  ("email", kv.get("GLOBAL_EMAIL", "-")),
1139
- ("share LAN", kv.get("GLOBAL_SHARE_LAN", "no")),
1140
1160
  ("tunnel", tunnel_state()),
1141
1161
  ("last report", kv.get("GLOBAL_LAST_REPORT_AT", "-")),
1162
+ ("last seen", kv.get("GLOBAL_LAST_SEEN_AT", "-")),
1142
1163
  ]
1143
1164
  if kv.get("GLOBAL_REASON"):
1144
1165
  fields.append(("reason", kv["GLOBAL_REASON"]))
@@ -1147,6 +1168,8 @@ def cmd_status(refresh: bool = True) -> int:
1147
1168
 
1148
1169
  if current == "unverified":
1149
1170
  rows += ["", " serverrf global verify <code>"]
1171
+ if refused:
1172
+ rows += ["", f"(refused by RemoteRF Global ({refused}))"]
1150
1173
  if unreachable:
1151
1174
  rows += ["", f"(could not reach RemoteRF Global just now: {unreachable})"]
1152
1175
 
@@ -1097,9 +1097,9 @@ def _cmd_status() -> None:
1097
1097
  ("", ""),
1098
1098
  ("global", f"{state} -- {g._STATE_SUMMARY.get(state, '')}".rstrip(" -")),
1099
1099
  ("hostname", g.hostname() or "-"),
1100
- ("share LAN", kv.get("GLOBAL_SHARE_LAN", "no")),
1101
1100
  ("tunnel", g.tunnel_state()),
1102
1101
  ("last report", (kv.get("GLOBAL_LAST_REPORT_AT") or "-").split(".")[0]),
1102
+ ("last seen", (kv.get("GLOBAL_LAST_SEEN_AT") or "-").split(".")[0]),
1103
1103
  ]
1104
1104
  else:
1105
1105
  fields += [("", ""), ("global", "not participating (serverrf global join)")]