RemoteRF 2.6.1__tar.gz → 2.11.0__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 (115) hide show
  1. {remoterf-2.6.1/src/remoterf.egg-info → remoterf-2.11.0}/PKG-INFO +11 -11
  2. {remoterf-2.6.1 → remoterf-2.11.0}/README.md +10 -10
  3. {remoterf-2.6.1 → remoterf-2.11.0}/pyproject.toml +1 -1
  4. remoterf-2.11.0/src/remoterf/admin.py +411 -0
  5. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/banner.py +1 -1
  6. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/config/config.py +4 -1
  7. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/app.py +561 -351
  8. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/availability.py +6 -5
  9. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/direct_path.py +74 -10
  10. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/dynamic_v2_transport.py +6 -3
  11. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/grpc_acc.py +28 -11
  12. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/grpc_client.py +61 -16
  13. remoterf-2.11.0/src/remoterf/core/messages.py +155 -0
  14. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/backend.py +17 -6
  15. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/direct.py +2 -0
  16. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/homes.py +174 -46
  17. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/http.py +16 -3
  18. remoterf-2.11.0/src/remoterf/deployment/lanpass.py +12 -0
  19. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/dynamic_device.py +89 -10
  20. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/hackrf/hackrf_remote.py +2 -2
  21. remoterf-2.11.0/src/remoterf/drivers/schema_review.py +97 -0
  22. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/remoterf_cli.py +49 -7
  23. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/version.py +12 -0
  24. {remoterf-2.6.1 → remoterf-2.11.0/src/remoterf.egg-info}/PKG-INFO +11 -11
  25. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.egg-info/SOURCES.txt +9 -0
  26. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_banner.py +3 -3
  27. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_batch.py +19 -2
  28. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_bundled_drivers.py +4 -2
  29. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_deployment_accounts.py +3 -3
  30. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_device_routes.py +186 -50
  31. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_direct_path_partner.py +3 -1
  32. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_direct_path_race.py +3 -1
  33. remoterf-2.11.0/tests/test_direct_path_relay.py +116 -0
  34. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_federated_devices.py +6 -3
  35. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_fetch_idl_fallback.py +2 -2
  36. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_gate_i_direct_path.py +8 -5
  37. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_homes.py +65 -6
  38. remoterf-2.11.0/tests/test_lan_fallback.py +158 -0
  39. remoterf-2.11.0/tests/test_messages.py +321 -0
  40. remoterf-2.11.0/tests/test_remote_admin.py +154 -0
  41. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_rx_readahead.py +2 -1
  42. remoterf-2.11.0/tests/test_schema_review.py +182 -0
  43. {remoterf-2.6.1 → remoterf-2.11.0}/LICENSE +0 -0
  44. {remoterf-2.6.1 → remoterf-2.11.0}/setup.cfg +0 -0
  45. {remoterf-2.6.1 → remoterf-2.11.0}/setup.py +0 -0
  46. {remoterf-2.6.1 → remoterf-2.11.0}/src/_remoterf_alias.py +0 -0
  47. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/__init__.py +0 -0
  48. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/__init__.py +0 -0
  49. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/__init__.py +0 -0
  50. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  51. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  52. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  53. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/grpc.proto +0 -0
  54. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  55. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  56. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/grpc/v2_codec.py +0 -0
  57. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/tos_notice.txt +0 -0
  58. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/__init__.py +0 -0
  59. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/ansi_codes.py +0 -0
  60. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/api_token.py +0 -0
  61. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/list_string.py +0 -0
  62. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/common/utils/process_arg.py +0 -0
  63. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/config/__init__.py +0 -0
  64. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/config/cert_fetcher.py +0 -0
  65. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/__init__.py +0 -0
  66. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/acc_login.py +0 -0
  67. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/banner_note.txt +0 -0
  68. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/secure_channel.py +0 -0
  69. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/core/v2_errors.py +0 -0
  70. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/__init__.py +0 -0
  71. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/deployment/state.py +0 -0
  72. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/__init__.py +0 -0
  73. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/_virtual.py +0 -0
  74. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  75. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  76. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/dynamic_v2.py +0 -0
  77. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  78. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  79. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  80. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  81. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  82. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/support/__init__.py +0 -0
  83. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  84. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/support/uhd.py +0 -0
  85. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  86. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  87. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  88. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  89. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/usrp/__init__.py +0 -0
  90. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  91. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  92. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.egg-info/dependency_links.txt +0 -0
  93. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.egg-info/entry_points.txt +0 -0
  94. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.egg-info/requires.txt +0 -0
  95. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.egg-info/top_level.txt +0 -0
  96. {remoterf-2.6.1 → remoterf-2.11.0}/src/remoterf.py +0 -0
  97. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_cli_login_fresh.py +0 -0
  98. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_config_address.py +0 -0
  99. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_devices_api.py +0 -0
  100. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_direct_path_fixed.py +0 -0
  101. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_direct_path_global.py +0 -0
  102. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_dynamic_device_codegen.py +0 -0
  103. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_dynamic_v2.py +0 -0
  104. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_finish_prompt.py +0 -0
  105. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_grpc_client_target.py +0 -0
  106. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_native_account_credentials.py +0 -0
  107. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_native_devices.py +0 -0
  108. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_old_import_name.py +0 -0
  109. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_partner_dynamic_v2.py +0 -0
  110. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_pluto_virtual.py +0 -0
  111. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_process_arg.py +0 -0
  112. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_relay_paging.py +0 -0
  113. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_remoterf_cli.py +0 -0
  114. {remoterf-2.6.1 → remoterf-2.11.0}/tests/test_ti_mmwave.py +0 -0
  115. {remoterf-2.6.1 → remoterf-2.11.0}/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.1
3
+ Version: 2.11.0
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
@@ -39,7 +39,7 @@ To check your current Python version, open a terminal and run:
39
39
  python --version
40
40
  ```
41
41
 
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.
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 deployment servers.
43
43
 
44
44
  ## Installation
45
45
 
@@ -147,9 +147,9 @@ not close the physical radio. `read_samples` and `read_bytes` are bounded to a
147
147
  4 MiB response. Call them repeatedly for longer captures. Native asynchronous
148
148
  callbacks are not transported remotely.
149
149
 
150
- Through the RemoteRF Global relay one reply is capped at 768 KiB. A lab on a
150
+ Through the RemoteRF Global relay one reply is capped at 768 KiB. A deployment on a
151
151
  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
152
+ pages; an older deployment refuses it with `response_too_large`, so keep reads there
153
153
  to about 98k complex64 samples (`read_samples(65536)` is safe). LAN and direct
154
154
  paths are not capped.
155
155
 
@@ -240,7 +240,7 @@ P2P, automatic routing, or global resource IDs. Those are future versions.
240
240
  The client-side software behaves identically whether you point it at a LAN
241
241
  server or at a RemoteRF Global v0 endpoint.
242
242
 
243
- ### RemoteRF Global v2 Phase-1 development boundary
243
+ ### RemoteRF Global Phase-1 development boundary
244
244
 
245
245
  The disposable Global v1 account-selection and deployment-session client was
246
246
  removed at Gate C. The normal CLI supports direct/native gRPC accounts and
@@ -249,7 +249,7 @@ username, email, or home token as destination credentials.
249
249
 
250
250
  Gate G adds permission visibility to the ordinary `perms` command. It renders
251
251
  HOME-local groups plus current destination-signed federated groups with their
252
- contract/version/state and retrieval time. An unreachable destination is
252
+ contract state and retrieval time. An unreachable destination is
253
253
  explicitly labeled as unsigned transport status, never as an authoritative
254
254
  empty group result. This works when the HOME has zero devices.
255
255
 
@@ -356,7 +356,7 @@ Nothing else needs changing!
356
356
 
357
357
  #### List devices and availability
358
358
 
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:
359
+ 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
360
 
361
361
  ```python
362
362
  import remoterf
@@ -365,10 +365,10 @@ rows = remoterf.devices() # prints a table and returns it as a list of dicts
365
365
  ```
366
366
 
367
367
  ```
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 -
368
+ DEVICE (token=) TYPE ONLINE NOW NEXT
369
+ Pluto_01@deployment pluto online free 14:30
370
+ Pluto_02@deployment pluto online in use until 13:45 -
371
+ Pluto_03@deployment pluto offline yours until 15:00 -
372
372
  ```
373
373
 
374
374
  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.1"
7
+ version = "2.11.0"
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"
@@ -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:]))
@@ -113,7 +113,7 @@ def _panel_lines(
113
113
  top = f"{top_left}{label}{horizontal * (PANEL_INNER_WIDTH - len(label))}{top_right}"
114
114
  content = [
115
115
  f"{marker} www.remoterf.net",
116
- "Created by https://wireless.ee.ucla.edu",
116
+ "Created by the Wireless Lab at UCLA",
117
117
  f"VERSION {version}",
118
118
  ]
119
119
  # Before a target is chosen there is no server to name; an "NA" row would
@@ -275,14 +275,17 @@ def configure(host: str, port: int, cert_port: int) -> int:
275
275
  "REMOTERF_ADDR": f"{host}:{grpc_port}",
276
276
  "REMOTERF_CA_CERT": str(ca_out),
277
277
  "REMOTERF_PROFILE": profile,
278
+ "REMOTERF_CERT_PORT": str(cert_port),
278
279
  })
279
280
  # This profile is what the "default" home was adopted from. A new address
280
281
  # or a re-fetched CA makes that adoption stale (its pin would now refuse
281
- # this very server), so drop it and let the next login adopt this one.
282
+ # this very server), so replace it with this one, the next `-l` target.
282
283
  try:
283
284
  from ..deployment import homes
284
285
 
285
286
  homes.forget_home(profile)
287
+ homes.forget_login(profile) # its session belongs to the old server
288
+ homes.migrate_legacy_target(configured=True)
286
289
  except Exception: # noqa: BLE001 - never fail configuration over bookkeeping
287
290
  pass
288
291