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.
- {remoterf-2.6.2/src/remoterf.egg-info → remoterf-2.12.2}/PKG-INFO +13 -11
- {remoterf-2.6.2 → remoterf-2.12.2}/README.md +10 -10
- {remoterf-2.6.2 → remoterf-2.12.2}/pyproject.toml +6 -2
- remoterf-2.12.2/src/remoterf/admin.py +411 -0
- remoterf-2.12.2/src/remoterf/agent_cli.py +370 -0
- remoterf-2.12.2/src/remoterf/agent_docs.py +265 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/banner.py +1 -1
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/config.py +4 -1
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/app.py +549 -622
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/availability.py +20 -70
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/direct_path.py +74 -10
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/dynamic_v2_transport.py +6 -3
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/grpc_acc.py +48 -20
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/grpc_client.py +97 -24
- remoterf-2.12.2/src/remoterf/core/messages.py +155 -0
- remoterf-2.12.2/src/remoterf/data/agent_guide.md +135 -0
- remoterf-2.12.2/src/remoterf/data/mcp_tools.json +126 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/backend.py +45 -6
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/direct.py +2 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/homes.py +174 -46
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/http.py +16 -3
- remoterf-2.12.2/src/remoterf/deployment/lanpass.py +12 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_device.py +89 -10
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +2 -2
- remoterf-2.12.2/src/remoterf/drivers/schema_review.py +97 -0
- remoterf-2.12.2/src/remoterf/mcp_server.py +324 -0
- remoterf-2.12.2/src/remoterf/ops.py +811 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/remoterf_cli.py +148 -20
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/version.py +12 -0
- {remoterf-2.6.2 → remoterf-2.12.2/src/remoterf.egg-info}/PKG-INFO +13 -11
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/SOURCES.txt +19 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/requires.txt +3 -0
- remoterf-2.12.2/tests/test_agent_cli.py +294 -0
- remoterf-2.12.2/tests/test_agent_docs.py +136 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_banner.py +3 -3
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_batch.py +19 -2
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_bundled_drivers.py +4 -2
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_cli_login_fresh.py +25 -2
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_deployment_accounts.py +3 -3
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_device_routes.py +186 -50
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_devices_api.py +21 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_partner.py +3 -1
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_race.py +3 -1
- remoterf-2.12.2/tests/test_direct_path_relay.py +116 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_federated_devices.py +22 -3
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_fetch_idl_fallback.py +2 -2
- remoterf-2.12.2/tests/test_finish_prompt.py +36 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_gate_i_direct_path.py +8 -5
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_homes.py +79 -14
- remoterf-2.12.2/tests/test_lan_fallback.py +158 -0
- remoterf-2.12.2/tests/test_mcp_server.py +223 -0
- remoterf-2.12.2/tests/test_messages.py +321 -0
- remoterf-2.12.2/tests/test_ops.py +443 -0
- remoterf-2.12.2/tests/test_remote_admin.py +154 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_rx_readahead.py +2 -1
- remoterf-2.12.2/tests/test_schema_review.py +182 -0
- remoterf-2.6.2/tests/test_finish_prompt.py +0 -20
- {remoterf-2.6.2 → remoterf-2.12.2}/LICENSE +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/setup.cfg +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/setup.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/_remoterf_alias.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc.proto +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/tos_notice.txt +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/api_token.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/list_string.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/common/utils/process_arg.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/config/cert_fetcher.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/acc_login.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/banner_note.txt +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/secure_channel.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/core/v2_errors.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/deployment/state.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/_virtual.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/entry_points.txt +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.egg-info/top_level.txt +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/src/remoterf.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_config_address.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_fixed.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_direct_path_global.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_dynamic_device_codegen.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_dynamic_v2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_grpc_client_target.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_native_account_credentials.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_native_devices.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_old_import_name.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_partner_dynamic_v2.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_pluto_virtual.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_process_arg.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_relay_paging.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_remoterf_cli.py +0 -0
- {remoterf-2.6.2 → remoterf-2.12.2}/tests/test_ti_mmwave.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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=)
|
|
369
|
-
Pluto_01@
|
|
370
|
-
Pluto_02@
|
|
371
|
-
Pluto_03@
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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=)
|
|
344
|
-
Pluto_01@
|
|
345
|
-
Pluto_02@
|
|
346
|
-
Pluto_03@
|
|
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.
|
|
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:]))
|