RemoteRF 2.6.2__tar.gz → 2.12.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 (126) hide show
  1. {remoterf-2.6.2/src/remoterf.egg-info → remoterf-2.12.2}/PKG-INFO +13 -11
  2. {remoterf-2.6.2 → remoterf-2.12.2}/README.md +10 -10
  3. {remoterf-2.6.2 → remoterf-2.12.2}/pyproject.toml +6 -2
  4. remoterf-2.12.2/src/remoterf/admin.py +411 -0
  5. remoterf-2.12.2/src/remoterf/agent_cli.py +370 -0
  6. remoterf-2.12.2/src/remoterf/agent_docs.py +265 -0
  7. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/banner.py +1 -1
  8. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/config.py +4 -1
  9. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/app.py +549 -622
  10. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/availability.py +20 -70
  11. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/direct_path.py +74 -10
  12. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/dynamic_v2_transport.py +6 -3
  13. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/grpc_acc.py +48 -20
  14. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/grpc_client.py +97 -24
  15. remoterf-2.12.2/src/remoterf/core/messages.py +155 -0
  16. remoterf-2.12.2/src/remoterf/data/agent_guide.md +135 -0
  17. remoterf-2.12.2/src/remoterf/data/mcp_tools.json +126 -0
  18. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/backend.py +45 -6
  19. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/direct.py +2 -0
  20. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/homes.py +174 -46
  21. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/http.py +16 -3
  22. remoterf-2.12.2/src/remoterf/deployment/lanpass.py +12 -0
  23. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_device.py +89 -10
  24. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +2 -2
  25. remoterf-2.12.2/src/remoterf/drivers/schema_review.py +97 -0
  26. remoterf-2.12.2/src/remoterf/mcp_server.py +324 -0
  27. remoterf-2.12.2/src/remoterf/ops.py +811 -0
  28. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/remoterf_cli.py +148 -20
  29. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/version.py +12 -0
  30. {remoterf-2.6.2 → remoterf-2.12.2/src/remoterf.egg-info}/PKG-INFO +13 -11
  31. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/SOURCES.txt +19 -0
  32. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/requires.txt +3 -0
  33. remoterf-2.12.2/tests/test_agent_cli.py +294 -0
  34. remoterf-2.12.2/tests/test_agent_docs.py +136 -0
  35. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_banner.py +3 -3
  36. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_batch.py +19 -2
  37. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_bundled_drivers.py +4 -2
  38. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_cli_login_fresh.py +25 -2
  39. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_deployment_accounts.py +3 -3
  40. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_device_routes.py +186 -50
  41. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_devices_api.py +21 -0
  42. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_partner.py +3 -1
  43. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_race.py +3 -1
  44. remoterf-2.12.2/tests/test_direct_path_relay.py +116 -0
  45. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_federated_devices.py +22 -3
  46. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_fetch_idl_fallback.py +2 -2
  47. remoterf-2.12.2/tests/test_finish_prompt.py +36 -0
  48. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_gate_i_direct_path.py +8 -5
  49. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_homes.py +79 -14
  50. remoterf-2.12.2/tests/test_lan_fallback.py +158 -0
  51. remoterf-2.12.2/tests/test_mcp_server.py +223 -0
  52. remoterf-2.12.2/tests/test_messages.py +321 -0
  53. remoterf-2.12.2/tests/test_ops.py +443 -0
  54. remoterf-2.12.2/tests/test_remote_admin.py +154 -0
  55. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_rx_readahead.py +2 -1
  56. remoterf-2.12.2/tests/test_schema_review.py +182 -0
  57. remoterf-2.6.2/tests/test_finish_prompt.py +0 -20
  58. {remoterf-2.6.2 → remoterf-2.12.2}/LICENSE +0 -0
  59. {remoterf-2.6.2 → remoterf-2.12.2}/setup.cfg +0 -0
  60. {remoterf-2.6.2 → remoterf-2.12.2}/setup.py +0 -0
  61. {remoterf-2.6.2 → remoterf-2.12.2}/src/_remoterf_alias.py +0 -0
  62. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/__init__.py +0 -0
  63. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/__init__.py +0 -0
  64. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/__init__.py +0 -0
  65. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  66. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  67. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  68. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc.proto +0 -0
  69. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  70. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  71. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
  72. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/tos_notice.txt +0 -0
  73. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/__init__.py +0 -0
  74. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
  75. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/api_token.py +0 -0
  76. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/list_string.py +0 -0
  77. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/process_arg.py +0 -0
  78. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/__init__.py +0 -0
  79. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/cert_fetcher.py +0 -0
  80. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/__init__.py +0 -0
  81. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/acc_login.py +0 -0
  82. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/banner_note.txt +0 -0
  83. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/secure_channel.py +0 -0
  84. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/v2_errors.py +0 -0
  85. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/__init__.py +0 -0
  86. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/state.py +0 -0
  87. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/__init__.py +0 -0
  88. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/_virtual.py +0 -0
  89. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  90. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  91. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
  92. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  93. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  94. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  95. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  96. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  97. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/__init__.py +0 -0
  98. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  99. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd.py +0 -0
  100. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  101. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  102. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  103. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  104. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
  105. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  106. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  107. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
  108. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/entry_points.txt +0 -0
  109. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/top_level.txt +0 -0
  110. {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.py +0 -0
  111. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_config_address.py +0 -0
  112. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_fixed.py +0 -0
  113. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_global.py +0 -0
  114. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_dynamic_device_codegen.py +0 -0
  115. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_dynamic_v2.py +0 -0
  116. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_grpc_client_target.py +0 -0
  117. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_native_account_credentials.py +0 -0
  118. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_native_devices.py +0 -0
  119. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_old_import_name.py +0 -0
  120. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_partner_dynamic_v2.py +0 -0
  121. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_pluto_virtual.py +0 -0
  122. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_process_arg.py +0 -0
  123. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_relay_paging.py +0 -0
  124. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_remoterf_cli.py +0 -0
  125. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_ti_mmwave.py +0 -0
  126. {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_upgrade_rescue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.6.2
3
+ Version: 2.12.2
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
5
  Author-email: Ethan Ge <ethoGalaxy@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -21,6 +21,8 @@ Requires-Dist: pydantic<3.0,>=2.6
21
21
  Requires-Dist: keyring<26,>=24
22
22
  Requires-Dist: aioice<0.11,>=0.10
23
23
  Requires-Dist: aioquic<2.0,>=1.2
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp<3,>=2.3; extra == "mcp"
24
26
  Dynamic: license-file
25
27
 
26
28
  # Remote RF
@@ -39,7 +41,7 @@ To check your current Python version, open a terminal and run:
39
41
  python --version
40
42
  ```
41
43
 
42
- - **UCLA VPN**: Please ensure that you are connected to the UCLA VPN. You can download and configure the VPN client from the following link: [UCLA VPN Client Download](https://www.it.ucla.edu/it-support-center/services/virtual-private-network-vpn-clients). If you’re not connected to the VPN, you will not have access to the lab servers.
44
+ - **UCLA VPN**: Please ensure that you are connected to the UCLA VPN. You can download and configure the VPN client from the following link: [UCLA VPN Client Download](https://www.it.ucla.edu/it-support-center/services/virtual-private-network-vpn-clients). If you’re not connected to the VPN, you will not have access to the deployment servers.
43
45
 
44
46
  ## Installation
45
47
 
@@ -147,9 +149,9 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
147
149
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
148
150
  callbacks are not transported remotely.
149
151
 
150
- Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
152
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A deployment on a
151
153
  current Server keeps a larger reply (up to 4 MiB) and the client fetches it in
152
- pages; an older lab refuses it with `response_too_large`, so keep reads there
154
+ pages; an older deployment refuses it with `response_too_large`, so keep reads there
153
155
  to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
154
156
  paths are not capped.
155
157
 
@@ -240,7 +242,7 @@ P2P, automatic routing, or global resource IDs. Those are future versions.
240
242
  The client-side software behaves identically whether you point it at a LAN
241
243
  server or at a RemoteRF Global v0 endpoint.
242
244
 
243
- ### RemoteRF Global v2 Phase-1 development boundary
245
+ ### RemoteRF Global Phase-1 development boundary
244
246
 
245
247
  The disposable Global v1 account-selection and deployment-session client was
246
248
  removed at Gate C. The normal CLI supports direct/native gRPC accounts and
@@ -249,7 +251,7 @@ username, email, or home token as destination credentials.
249
251
 
250
252
  Gate G adds permission visibility to the ordinary `perms` command. It renders
251
253
  HOME-local groups plus current destination-signed federated groups with their
252
- contract/version/state and retrieval time. An unreachable destination is
254
+ contract state and retrieval time. An unreachable destination is
253
255
  explicitly labeled as unsigned transport status, never as an authoritative
254
256
  empty group result. This works when the HOME has zero devices.
255
257
 
@@ -356,7 +358,7 @@ Nothing else needs changing!
356
358
 
357
359
  #### List devices and availability
358
360
 
359
- With a saved login (lab or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
361
+ With a saved login (deployment or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
360
362
 
361
363
  ```python
362
364
  import remoterf
@@ -365,10 +367,10 @@ rows = remoterf.devices() # prints a table and returns it as a list of dicts
365
367
  ```
366
368
 
367
369
  ```
368
- DEVICE (token=) TYPE ONLINE NOW NEXT
369
- Pluto_01@lab pluto online free 14:30
370
- Pluto_02@lab pluto online in use until 13:45 -
371
- Pluto_03@lab pluto offline yours until 15:00 -
370
+ DEVICE (token=) TYPE ONLINE NOW NEXT
371
+ Pluto_01@deployment pluto online free 14:30
372
+ Pluto_02@deployment pluto online in use until 13:45 -
373
+ Pluto_03@deployment pluto offline yours until 15:00 -
372
374
  ```
373
375
 
374
376
  Each account may ask once every 10 seconds; a repeat call within that reprints the last table with the remaining wait.
@@ -14,7 +14,7 @@ To check your current Python version, open a terminal and run:
14
14
  python --version
15
15
  ```
16
16
 
17
- - **UCLA VPN**: Please ensure that you are connected to the UCLA VPN. You can download and configure the VPN client from the following link: [UCLA VPN Client Download](https://www.it.ucla.edu/it-support-center/services/virtual-private-network-vpn-clients). If you’re not connected to the VPN, you will not have access to the lab servers.
17
+ - **UCLA VPN**: Please ensure that you are connected to the UCLA VPN. You can download and configure the VPN client from the following link: [UCLA VPN Client Download](https://www.it.ucla.edu/it-support-center/services/virtual-private-network-vpn-clients). If you’re not connected to the VPN, you will not have access to the deployment servers.
18
18
 
19
19
  ## Installation
20
20
 
@@ -122,9 +122,9 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
122
122
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
123
123
  callbacks are not transported remotely.
124
124
 
125
- Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
125
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A deployment on a
126
126
  current Server keeps a larger reply (up to 4 MiB) and the client fetches it in
127
- pages; an older lab refuses it with `response_too_large`, so keep reads there
127
+ pages; an older deployment refuses it with `response_too_large`, so keep reads there
128
128
  to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
129
129
  paths are not capped.
130
130
 
@@ -215,7 +215,7 @@ P2P, automatic routing, or global resource IDs. Those are future versions.
215
215
  The client-side software behaves identically whether you point it at a LAN
216
216
  server or at a RemoteRF Global v0 endpoint.
217
217
 
218
- ### RemoteRF Global v2 Phase-1 development boundary
218
+ ### RemoteRF Global Phase-1 development boundary
219
219
 
220
220
  The disposable Global v1 account-selection and deployment-session client was
221
221
  removed at Gate C. The normal CLI supports direct/native gRPC accounts and
@@ -224,7 +224,7 @@ username, email, or home token as destination credentials.
224
224
 
225
225
  Gate G adds permission visibility to the ordinary `perms` command. It renders
226
226
  HOME-local groups plus current destination-signed federated groups with their
227
- contract/version/state and retrieval time. An unreachable destination is
227
+ contract state and retrieval time. An unreachable destination is
228
228
  explicitly labeled as unsigned transport status, never as an authoritative
229
229
  empty group result. This works when the HOME has zero devices.
230
230
 
@@ -331,7 +331,7 @@ Nothing else needs changing!
331
331
 
332
332
  #### List devices and availability
333
333
 
334
- With a saved login (lab or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
334
+ With a saved login (deployment or Global), list the devices you can use, each with the handle to paste into `token=`, and whether it is free right now:
335
335
 
336
336
  ```python
337
337
  import remoterf
@@ -340,10 +340,10 @@ rows = remoterf.devices() # prints a table and returns it as a list of dicts
340
340
  ```
341
341
 
342
342
  ```
343
- DEVICE (token=) TYPE ONLINE NOW NEXT
344
- Pluto_01@lab pluto online free 14:30
345
- Pluto_02@lab pluto online in use until 13:45 -
346
- Pluto_03@lab pluto offline yours until 15:00 -
343
+ DEVICE (token=) TYPE ONLINE NOW NEXT
344
+ Pluto_01@deployment pluto online free 14:30
345
+ Pluto_02@deployment pluto online in use until 13:45 -
346
+ Pluto_03@deployment pluto offline yours until 15:00 -
347
347
  ```
348
348
 
349
349
  Each account may ask once every 10 seconds; a repeat call within that reprints the last table with the remaining wait.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.6.2"
7
+ version = "2.12.2"
8
8
  description = "A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -30,6 +30,10 @@ classifiers = [
30
30
  "Operating System :: OS Independent",
31
31
  ]
32
32
 
33
+ [project.optional-dependencies]
34
+ # `remoterf mcp`: the local MCP server for AI assistants.
35
+ mcp = ["mcp>=2.3,<3"]
36
+
33
37
  [project.scripts]
34
38
  remoterf = "remoterf.remoterf_cli:main"
35
39
 
@@ -43,4 +47,4 @@ py-modules = ["remoterf", "_remoterf_alias"]
43
47
  where = ["src"]
44
48
 
45
49
  [tool.setuptools.package-data]
46
- "*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto"]
50
+ "*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto", "*.md", "*.json"]
@@ -0,0 +1,411 @@
1
+ """`remoterf --remote-admin`: a lab's console, for its pinned admins, from anywhere.
2
+
3
+ The lab runs an SSH server reachable only inside its gRPC port
4
+ (RemoteAdminV1.Tunnel); this side is the stock OpenSSH client with
5
+ `--admin-pipe` as its ProxyCommand, over the same routes `-l` uses (LAN first,
6
+ then the Global relay). SSH does the security: the lab's host key is pinned
7
+ in our known_hosts (first from the console's pairing code), this machine's
8
+ device key is pinned at the lab, and the remote-admin password is asked every
9
+ time. Nothing secret is cached here, and this process only pipes bytes.
10
+
11
+ remoterf --remote-admin [lab] log in (a menu without a lab)
12
+ remoterf --remote-admin pair <user>@<lab> <code>
13
+ remoterf --remote-admin trust <lab> SHA256:… trust a host key read somewhere trusted
14
+ remoterf --remote-admin hostkey <lab> show the lab's current host key
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import shlex
25
+ import shutil
26
+ import socket
27
+ import subprocess
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ SERVICE = "/remote_rf.RemoteAdminV1/"
32
+ MD_LABEL = "x-remoterf-device-label"
33
+ LAB_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.:_\[\]-]*$")
34
+ INSTALL_SSH = ("OpenSSH (ssh and ssh-keygen) is required for remote admin. Install it: macOS and most "
35
+ "Linux have it; Debian/Ubuntu: sudo apt install openssh-client; Windows 10+: "
36
+ "Settings > Apps > Optional features > OpenSSH Client.")
37
+
38
+
39
+ class AdminError(Exception):
40
+ pass
41
+
42
+
43
+ # -----------------------------
44
+ # Files: ~/.config/remoterf-client/admin/
45
+ # -----------------------------
46
+
47
+ def admin_dir() -> Path:
48
+ from .config.config import _config_root
49
+
50
+ return _config_root() / "admin"
51
+
52
+
53
+ def key_path() -> Path:
54
+ return admin_dir() / "id_ed25519"
55
+
56
+
57
+ def known_hosts_path() -> Path:
58
+ return admin_dir() / "known_hosts"
59
+
60
+
61
+ def _labs_path() -> Path:
62
+ return admin_dir() / "labs.json"
63
+
64
+
65
+ def labs() -> dict:
66
+ try:
67
+ return json.loads(_labs_path().read_text())
68
+ except (OSError, ValueError):
69
+ return {}
70
+
71
+
72
+ def _save_lab(name: str, entry: dict) -> None:
73
+ data = labs()
74
+ data[name] = entry
75
+ admin_dir().mkdir(mode=0o700, parents=True, exist_ok=True)
76
+ _labs_path().write_text(json.dumps(data, indent=2, sort_keys=True))
77
+
78
+
79
+ def _check_ssh() -> None:
80
+ if shutil.which("ssh") is None or shutil.which("ssh-keygen") is None:
81
+ raise AdminError(INSTALL_SSH)
82
+
83
+
84
+ def ensure_device_key() -> Path:
85
+ path = key_path()
86
+ if not path.exists():
87
+ _check_ssh()
88
+ admin_dir().mkdir(mode=0o700, parents=True, exist_ok=True)
89
+ subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", f"remoterf-admin@{device_label()}",
90
+ "-f", str(path)], check=True)
91
+ return path
92
+
93
+
94
+ def device_label() -> str:
95
+ return re.sub(r"[^A-Za-z0-9._-]", "", socket.gethostname().split(".")[0])[:40] or "unnamed"
96
+
97
+
98
+ # -----------------------------
99
+ # Labs, routes, the tunnel
100
+ # -----------------------------
101
+
102
+ def split_login(text: str) -> tuple[str | None, str]:
103
+ """`alice@ucla` -> ("alice", "ucla"); `jdoe@global@ucla` -> ("jdoe@global", "ucla"); `ucla` -> (None, "ucla")."""
104
+ user, at, lab = text.strip().rpartition("@")
105
+ lab = lab if at else text.strip()
106
+ if not LAB_RE.match(lab):
107
+ raise AdminError(f"Not a lab: {lab!r} (a name like ucla, or host:port)")
108
+ return (user or None), lab
109
+
110
+
111
+ def _alias(lab: str, deployment_id: str | None) -> str:
112
+ return "remoterf-" + (deployment_id or re.sub(r"[^A-Za-z0-9.-]", "-", lab))
113
+
114
+
115
+ def _address(lab: str) -> tuple[str, int] | None:
116
+ """`host:port`, `[v6]:port` or a bare IPv4 (port 12321): a lab that never joined Global."""
117
+ if lab.startswith("["):
118
+ host, _, port = lab[1:].partition("]:")
119
+ elif lab.count(":") == 1:
120
+ host, _, port = lab.partition(":")
121
+ elif ":" in lab:
122
+ raise AdminError(f"Write an IPv6 lab as [address]:port, not {lab!r}")
123
+ elif lab.count(".") == 3 and lab.replace(".", "").isdigit():
124
+ host, port = lab, "12321"
125
+ else:
126
+ return None
127
+ if not host or not port.isdigit():
128
+ raise AdminError(f"Not a lab address: {lab!r}")
129
+ return host, int(port)
130
+
131
+
132
+ def _resolve(lab: str):
133
+ """(grpc target, CA PEM, TLS server name, HostKeyAlias) for a lab: a saved home,
134
+ a Global name (discovered and saved), or a host:port that never joined Global."""
135
+ from .deployment import homes
136
+
137
+ address = _address(lab)
138
+ if address is not None:
139
+ from .config.cert_fetcher import fetch_ca_bytes
140
+
141
+ host, port = address
142
+ entry = labs().get(lab, {})
143
+ try:
144
+ certificate = fetch_ca_bytes(host, int(entry.get("cert_port") or int(port) + 1), timeout_sec=5.0)
145
+ except Exception as exc: # noqa: BLE001
146
+ raise AdminError(f"Could not reach {lab}: {exc}") from None
147
+ # TLS here is transport only: the SSH host key, pinned from the console, is the lab's identity.
148
+ return f"[{host}]:{port}" if ":" in host else f"{host}:{port}", certificate, None, _alias(lab, None)
149
+ try:
150
+ home = homes.load_home(lab)
151
+ except Exception: # noqa: BLE001 - not saved yet: Global's discovery names it
152
+ home = _rediscover(lab)
153
+ try:
154
+ route, certificate = homes.select_route(home, homes._configured_ca(lab, home))
155
+ except Exception as exc: # noqa: BLE001
156
+ # A new CA (`certs reset`): Global's current pin, as LAN fallback does. Anything else is the answer.
157
+ if home.get("deployment_id") is None or "unexpected certificate" not in str(exc):
158
+ raise AdminError(f"Could not reach {lab}: {exc}") from None
159
+ home = _rediscover(lab)
160
+ try:
161
+ route, certificate = homes.select_route(home)
162
+ except Exception as exc2: # noqa: BLE001
163
+ raise AdminError(f"Could not reach {lab}: {exc2}") from None
164
+ relay = next((r["host"] for r in home["routes"] if r["kind"] == "relay"), None)
165
+ return (f"{route['host']}:{route['port']}", certificate, relay if route["kind"] != "relay" else None,
166
+ _alias(lab, home.get("deployment_id")))
167
+
168
+
169
+ def _rediscover(lab: str) -> dict:
170
+ from .deployment import homes
171
+
172
+ if not homes.NAME_RE.match(lab):
173
+ raise AdminError(f"No saved home {lab!r}, and it is not a RemoteRF Global lab name")
174
+ try:
175
+ return homes.save_home(lab, homes.discover(f"{lab}.{homes.GLOBAL_DOMAIN}"))
176
+ except Exception as exc: # noqa: BLE001
177
+ raise AdminError(f"Could not find lab {lab!r}: {exc}") from None
178
+
179
+
180
+ def _channel(lab: str):
181
+ from .core.secure_channel import build_secure_channel
182
+
183
+ target, certificate, server_name, alias = _resolve(lab)
184
+ return build_secure_channel(target, certificate, tls_server_name=server_name), alias
185
+
186
+
187
+ def fetch_host_key(lab: str) -> tuple[str, str]:
188
+ """(OpenSSH public key line, HostKeyAlias). Not trusted by itself."""
189
+ import grpc
190
+
191
+ channel, alias = _channel(lab)
192
+ try:
193
+ line = channel.unary_unary(SERVICE + "HostKey")(b"", timeout=15).decode("ascii", "replace").strip()
194
+ except grpc.RpcError as exc:
195
+ raise AdminError(f"{lab}: {exc.details() or exc.code().name}") from None
196
+ finally:
197
+ channel.close()
198
+ try:
199
+ fingerprint(line)
200
+ except Exception: # noqa: BLE001
201
+ raise AdminError(f"{lab} sent something that is not a host key") from None
202
+ if not re.fullmatch(r"ssh-ed25519 [A-Za-z0-9+/]+={0,2}", line):
203
+ raise AdminError(f"{lab} sent something that is not a host key")
204
+ return line, alias
205
+
206
+
207
+ def fingerprint(line: str) -> str:
208
+ blob = base64.b64decode(line.split()[1], validate=True)
209
+ return "SHA256:" + base64.b64encode(hashlib.sha256(blob).digest()).decode().rstrip("=")
210
+
211
+
212
+ def trusted(alias: str, key_line: str | None = None) -> bool:
213
+ """Whether this alias has a pinned key here (that very key, when given)."""
214
+ try:
215
+ lines = known_hosts_path().read_text().splitlines()
216
+ except OSError:
217
+ return False
218
+ return any(l.split()[:1] == [alias] and (key_line is None or l.split()[1:3] == key_line.split()[:2])
219
+ for l in lines)
220
+
221
+
222
+ def trust_line(alias: str, line: str) -> None:
223
+ path = known_hosts_path()
224
+ try:
225
+ kept = [l for l in path.read_text().splitlines() if l.split()[:1] != [alias]]
226
+ except OSError:
227
+ kept = []
228
+ admin_dir().mkdir(mode=0o700, parents=True, exist_ok=True)
229
+ path.write_text("\n".join([*kept, f"{alias} {line}"]) + "\n")
230
+
231
+
232
+ def pipe(lab: str) -> int:
233
+ """The ProxyCommand: stdin/stdout <-> RemoteAdminV1.Tunnel. Errors go to stderr only."""
234
+ import grpc
235
+
236
+ try:
237
+ channel, _ = _channel(lab)
238
+ except Exception as exc: # noqa: BLE001
239
+ print(f"remoterf: {exc}", file=sys.stderr)
240
+ return 255
241
+
242
+ def chunks():
243
+ while True:
244
+ data = os.read(0, 65536)
245
+ if not data:
246
+ return
247
+ yield data
248
+
249
+ out = sys.stdout.buffer
250
+ try:
251
+ for data in channel.stream_stream(SERVICE + "Tunnel")(chunks(), metadata=((MD_LABEL, device_label()),)):
252
+ out.write(data)
253
+ out.flush()
254
+ except grpc.RpcError as exc:
255
+ print(f"remoterf: {lab}: {exc.details() or exc.code().name}", file=sys.stderr)
256
+ return 255
257
+ finally:
258
+ channel.close()
259
+ return 0
260
+
261
+
262
+ # -----------------------------
263
+ # ssh
264
+ # -----------------------------
265
+
266
+ def _ssh_path(path) -> str:
267
+ """A path as an ssh option value: quoted (spaces), `%` escaped (ssh expands tokens)."""
268
+ return '"' + str(path).replace("%", "%%") + '"'
269
+
270
+
271
+ def ssh_argv(lab: str, login: str, alias: str, *, strict: bool = True) -> list[str]:
272
+ # -P: never import from the directory ssh happens to run in (3.11+).
273
+ python = [sys.executable, *(["-P"] if sys.version_info >= (3, 11) else [])]
274
+ words = [*python, "-m", "remoterf.admin", "--admin-pipe", lab]
275
+ proxy = subprocess.list2cmdline(words) if os.name == "nt" else " ".join(shlex.quote(a) for a in words)
276
+ return ["ssh", "-F", "none", "-tt",
277
+ "-o", "ProxyCommand=" + proxy.replace("%", "%%"),
278
+ "-o", f"HostKeyAlias={alias}",
279
+ "-o", f"UserKnownHostsFile={_ssh_path(known_hosts_path())}",
280
+ "-o", f"GlobalKnownHostsFile={_ssh_path(os.devnull)}",
281
+ "-o", f"StrictHostKeyChecking={'yes' if strict else 'ask'}", "-o", "UpdateHostKeys=no",
282
+ "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none", "-i", str(key_path()).replace("%", "%%"),
283
+ "-o", "PreferredAuthentications=publickey,keyboard-interactive", "-o", "PasswordAuthentication=no",
284
+ "-o", "ServerAliveInterval=30", "-o", "ForwardAgent=no", "-o", "ForwardX11=no",
285
+ "-o", "ClearAllForwardings=yes", # no LogLevel=ERROR: it hides the lab's instructions
286
+ "-l", login, alias]
287
+
288
+
289
+ def _exec(argv: list[str]) -> int:
290
+ sys.stdout.flush()
291
+ if os.name == "posix":
292
+ os.execvp(argv[0], argv)
293
+ return subprocess.call(argv)
294
+
295
+
296
+ # -----------------------------
297
+ # Commands
298
+ # -----------------------------
299
+
300
+ def cmd_pair(target: str, code: str) -> int:
301
+ user, lab = split_login(target)
302
+ code, _, prefix = code.strip().partition(".")
303
+ if not user:
304
+ user = input(f"Your account at {lab}: ").strip()
305
+ if len(prefix) < 12:
306
+ raise AdminError("That code is missing its .<fingerprint> part; copy the whole line the console printed.")
307
+ _check_ssh()
308
+ line, alias = fetch_host_key(lab)
309
+ fp = fingerprint(line)
310
+ if not fp.split(":", 1)[1].startswith(prefix):
311
+ raise AdminError(f"{lab} presented host key {fp}, which is not the one the console's code names. "
312
+ "Nothing was trusted. Get a fresh code at the lab console (serverrf admins code).")
313
+ trust_line(alias, line)
314
+ ensure_device_key()
315
+ _save_lab(lab, {"user": user, "alias": alias})
316
+ print(f"Trusted {lab}'s admin host key {fp}.")
317
+ # The code rides in the login name: inside SSH, after the host key is verified.
318
+ return _exec(ssh_argv(lab, f"{user}#{code}", alias))
319
+
320
+
321
+ def cmd_login(target: str | None) -> int:
322
+ saved = labs()
323
+ if target is None:
324
+ if not saved:
325
+ raise AdminError("No labs yet. Pair first: remoterf --remote-admin pair <user>@<lab> <code> "
326
+ "(the lab console prints it: serverrf admins add <user>).")
327
+ names = sorted(saved)
328
+ if len(names) == 1:
329
+ target = names[0]
330
+ else:
331
+ for i, name in enumerate(names, 1):
332
+ print(f" {i}) {saved[name].get('user', '?')}@{name}")
333
+ pick = input("Lab: ").strip()
334
+ target = names[int(pick) - 1] if pick.isdigit() and 0 < int(pick) <= len(names) else pick
335
+ user, lab = split_login(target)
336
+ entry = saved.get(lab, {})
337
+ user = user or entry.get("user") or input(f"Your account at {lab}: ").strip()
338
+ _check_ssh()
339
+ ensure_device_key()
340
+ alias = entry.get("alias") or _resolve(lab)[3]
341
+ strict = trusted(alias)
342
+ if not strict:
343
+ print(f"This machine has not trusted {lab}'s admin host key. Safer: a code from the lab console, "
344
+ f"`remoterf --remote-admin trust {lab} SHA256:…` with the fingerprint from a trusted place, or a "
345
+ f"copy of {known_hosts_path()} from a paired machine. Accepting it below on first sight trusts the "
346
+ "network you are on.", file=sys.stderr)
347
+ _save_lab(lab, {**entry, "user": user, "alias": alias})
348
+ return _exec(ssh_argv(lab, user, alias, strict=strict))
349
+
350
+
351
+ def cmd_trust(lab: str, fp: str) -> int:
352
+ _, lab = split_login(lab)
353
+ line, alias = fetch_host_key(lab)
354
+ actual = fingerprint(line)
355
+ if actual != (fp if fp.startswith("SHA256:") else "SHA256:" + fp):
356
+ raise AdminError(f"{lab} presented {actual}, not {fp}. Nothing was trusted.")
357
+ trust_line(alias, line)
358
+ _save_lab(lab, {**labs().get(lab, {}), "alias": alias})
359
+ print(f"Trusted {lab}'s admin host key {actual}.")
360
+ return 0
361
+
362
+
363
+ def cmd_hostkey(lab: str) -> int:
364
+ _, lab = split_login(lab)
365
+ line, alias = fetch_host_key(lab)
366
+ state = ("trusted here" if trusted(alias, line) else
367
+ "NOT the key trusted here" if trusted(alias) else "not trusted here")
368
+ print(f"{lab} presents {fingerprint(line)} ({state}).")
369
+ print("Compare it with `serverrf admins hostkey` at the lab before trusting it.")
370
+ return 0
371
+
372
+
373
+ USAGE = """Usage:
374
+ remoterf --remote-admin [<user>@]<lab> the lab's console (a menu with no lab)
375
+ remoterf --remote-admin pair <user>@<lab> <code> first time on this machine (code from: serverrf admins add/code)
376
+ remoterf --remote-admin trust <lab> SHA256:… trust a host key fingerprint read somewhere trusted
377
+ remoterf --remote-admin hostkey <lab> show the lab's current host key"""
378
+
379
+
380
+ def main(argv: list[str]) -> int:
381
+ """argv after `--remote-admin` (or `--admin-pipe <lab>`)."""
382
+ if argv[:1] == ["--admin-pipe"]:
383
+ try: # stdout carries SSH bytes only, even on the way out
384
+ return pipe(argv[1]) if len(argv) == 2 else 2
385
+ except (KeyboardInterrupt, BrokenPipeError):
386
+ return 130
387
+ try:
388
+ if argv[:1] in (["-h"], ["--help"]):
389
+ print(USAGE)
390
+ return 0
391
+ verb = argv[0] if argv else None
392
+ if verb == "pair" and len(argv) == 3:
393
+ return cmd_pair(argv[1], argv[2])
394
+ if verb == "trust" and len(argv) == 3:
395
+ return cmd_trust(argv[1], argv[2])
396
+ if verb == "hostkey" and len(argv) == 2:
397
+ return cmd_hostkey(argv[1])
398
+ if verb in ("pair", "trust", "hostkey") or len(argv) > 1:
399
+ print(USAGE, file=sys.stderr)
400
+ return 2
401
+ return cmd_login(verb)
402
+ except (EOFError, KeyboardInterrupt):
403
+ print()
404
+ return 130
405
+ except Exception as exc: # noqa: BLE001 - AdminError and anything below it (ssh-keygen, routes)
406
+ print(f"remoterf: {exc}", file=sys.stderr)
407
+ return 1
408
+
409
+
410
+ if __name__ == "__main__":
411
+ raise SystemExit(main(sys.argv[1:]))