livsyn-rdp-cli 0.1.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.
- livsyn_rdp_cli-0.1.0/PKG-INFO +130 -0
- livsyn_rdp_cli-0.1.0/README.md +112 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/PKG-INFO +130 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/SOURCES.txt +13 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/dependency_links.txt +1 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/entry_points.txt +2 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/requires.txt +2 -0
- livsyn_rdp_cli-0.1.0/livsyn_rdp_cli.egg-info/top_level.txt +1 -0
- livsyn_rdp_cli-0.1.0/pyproject.toml +3 -0
- livsyn_rdp_cli-0.1.0/rdp_cli/__init__.py +3 -0
- livsyn_rdp_cli-0.1.0/rdp_cli/client.py +94 -0
- livsyn_rdp_cli-0.1.0/rdp_cli/main.py +198 -0
- livsyn_rdp_cli-0.1.0/setup.cfg +36 -0
- livsyn_rdp_cli-0.1.0/tests/test_client.py +86 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livsyn-rdp-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RDP signed API client and managed SSH connection
|
|
5
|
+
Home-page: https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
6
|
+
Author: Livsyn Robotics
|
|
7
|
+
Project-URL: Source, https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: System :: Networking
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: http-message-signatures==2.0.0
|
|
17
|
+
Requires-Dist: typing-extensions==4.16.0
|
|
18
|
+
|
|
19
|
+
# RDP CLI
|
|
20
|
+
|
|
21
|
+
通过个人 AK/SK 查询授权设备、建立 SSH 连接并自动续租。Python 包名为 `livsyn-rdp-cli`,安装后的命令为 `rdp`。
|
|
22
|
+
|
|
23
|
+
客户端从 RDP 仓库拆出,后续在此独立维护。服务端采用管理员代发模式:技术管理员为技术员签发凭据,技术员使用本人凭据;超管保留管理权限。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
需要 Python 3.10+ 和本机 OpenSSH。支持 Linux/macOS,Windows 使用 WSL。
|
|
28
|
+
|
|
29
|
+
推荐使用 pipx 安装,安装后可以直接执行 `rdp`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install livsyn-rdp-cli
|
|
33
|
+
rdp --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
也可以安装到 Python 虚拟环境:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python3 -m venv .venv
|
|
40
|
+
.venv/bin/python -m pip install livsyn-rdp-cli
|
|
41
|
+
.venv/bin/rdp --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
升级时执行 `pipx upgrade livsyn-rdp-cli`,或在虚拟环境中执行 `.venv/bin/python -m pip install --upgrade livsyn-rdp-cli`。
|
|
45
|
+
|
|
46
|
+
从 PyPI 安装无需 GitLab 权限。需要从源码安装时,使用具有仓库读取权限的 Git 账号:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://git.lingrobotics.com/cloud_service/rdp-cli.git
|
|
50
|
+
cd rdp-cli
|
|
51
|
+
python3 -m venv .venv
|
|
52
|
+
.venv/bin/pip install .
|
|
53
|
+
.venv/bin/rdp --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
也可以直接从 GitLab 安装:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pipx install 'git+https://git.lingrobotics.com/cloud_service/rdp-cli.git'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
仓库认证使用已有 Git 凭据,不要把密码或访问令牌写进安装 URL。安装包仅包含客户端;连接设备仍需要平台签发的有效 AK/SK。
|
|
63
|
+
|
|
64
|
+
## 配置凭据
|
|
65
|
+
|
|
66
|
+
1. 由技术管理员登录 [RDP](https://rdp.lingrobotics.com),进入右上角“API 凭据”→“签发凭据”。
|
|
67
|
+
2. 选择技术员和有效期即可,名称自动生成,权限及客户范围自动带入,无需重复配置。将 AK 和仅显示一次的 SK 安全交付本人。
|
|
68
|
+
3. 技术员在运行客户端的机器上执行 `rdp configure`,按提示输入地址、AK、SK。默认地址为 `https://rdp.lingrobotics.com`;使用虚拟环境安装时执行 `.venv/bin/rdp configure`。
|
|
69
|
+
|
|
70
|
+
技术员可在平台查看和撤销本人凭据,不能自行签发、重置或扩权;重置请联系技术管理员。收回客户权限会实时生效,新增客户不会自动扩大旧凭据范围,需要签发新凭据。
|
|
71
|
+
|
|
72
|
+
SK 仅在创建时显示一次。配置程序交互读取 SK,优先使用已安装且可用的 Python keyring;否则保存到权限 `0600` 的 `~/.config/rdp/credentials.json`。可通过 `RDP_CREDENTIAL_FILE` 指定私有配置路径。不要把 SK 放入命令参数、源码、聊天或日志。
|
|
73
|
+
|
|
74
|
+
后续示例使用已加入 PATH 的 `rdp`;从仓库虚拟环境运行时,将其替换为 `.venv/bin/rdp`。
|
|
75
|
+
|
|
76
|
+
## 使用
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
rdp devices
|
|
80
|
+
rdp ssh 221
|
|
81
|
+
rdp ssh DEVICE_REF_UUID
|
|
82
|
+
rdp ssh 唯一设备名称
|
|
83
|
+
rdp sessions
|
|
84
|
+
rdp sessions close SESSION_ID
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
节点号和名称需要 `devices:read`;只有 `ssh:connect` 的凭据可以直接连接已知的稳定 `device_ref`。长期脚本优先保存稳定引用,每个窗口对应独立会话。
|
|
88
|
+
|
|
89
|
+
SSH 用户和端口由平台管理。CLI 自动签名、获取一次性票据、校验 Gateway 主机公钥并续租;退出后回收会话。网络故障按租约过期收回,不自动重跑远程命令。单个会话仍有 24 小时绝对上限。
|
|
90
|
+
|
|
91
|
+
当前支持交互 shell 和通过标准输入执行命令。检查命令示例(设备编号替换为本人有权访问的设备):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
printf 'hostname\nuptime\ndf -h\nexit\n' | rdp ssh 221
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
当前没有独立的 `rdp exec` 子命令。新模式不支持 SFTP/SCP、VS Code Remote、端口转发或 SSH 连接复用。直接使用 API 票据运行裸 SSH 不会自动续租。
|
|
98
|
+
|
|
99
|
+
## Codex 使用
|
|
100
|
+
|
|
101
|
+
在哪台机器运行 Codex,就在哪台机器安装并配置本客户端。凭据由用户在终端配置,模型无需读取或输出凭据文件。可以给 Codex 这样的指令:
|
|
102
|
+
|
|
103
|
+
> 使用本机 RDP CLI 查询授权设备,再连接设备 #221,检查主机名、运行时间和磁盘使用情况,完成后退出。凭据已经配置,不要读取或输出凭据文件。若命令不在 PATH,使用客户端虚拟环境中的 rdp 绝对路径。
|
|
104
|
+
|
|
105
|
+
## Python SDK
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from rdp_cli import Client
|
|
109
|
+
|
|
110
|
+
# 从调用端私有凭据配置取得以下值,不写进源码。
|
|
111
|
+
client = Client(origin, access_key, secret_key)
|
|
112
|
+
devices = client.request("GET", "/api/automation/v1/devices")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
SDK 负责 RFC 9421 签名、正文摘要、防重放 nonce 和 POST 幂等键。429/503 有限重试,每次重试使用新签名;SDK 不跟随重定向。创建会话的响应包含短期票据,不要直接打印或记录。
|
|
116
|
+
|
|
117
|
+
SDK 本身不启动 SSH 或续租线程;需要在线续租的交互连接使用 `rdp ssh`。CLI 已封装接口调用,技术员无需查看接口文档。完整契约见仅技术管理员和超管可访问的 [RDP OpenAPI 文档](https://rdp.lingrobotics.com/api-docs/automation)。
|
|
118
|
+
|
|
119
|
+
## 开发与打包
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python3 -m venv .venv
|
|
123
|
+
.venv/bin/pip install . build
|
|
124
|
+
.venv/bin/python -m unittest discover -s tests
|
|
125
|
+
.venv/bin/python -m build
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
构建后,使用 `python -m twine check --strict dist/*` 检查分发包,再通过已配置的 PyPI 发布凭据上传。发布 Token 不写入仓库或命令参数。
|
|
129
|
+
|
|
130
|
+
运行时代码最初取自 `cloud_service/rdp` 的提交 `e2766b6b4f0d313e3d2b45810726c7a437701c6a`,目录为 `cli/`。独立仓库不包含 RDP 后端、私有配置或运行时凭据。
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# RDP CLI
|
|
2
|
+
|
|
3
|
+
通过个人 AK/SK 查询授权设备、建立 SSH 连接并自动续租。Python 包名为 `livsyn-rdp-cli`,安装后的命令为 `rdp`。
|
|
4
|
+
|
|
5
|
+
客户端从 RDP 仓库拆出,后续在此独立维护。服务端采用管理员代发模式:技术管理员为技术员签发凭据,技术员使用本人凭据;超管保留管理权限。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
需要 Python 3.10+ 和本机 OpenSSH。支持 Linux/macOS,Windows 使用 WSL。
|
|
10
|
+
|
|
11
|
+
推荐使用 pipx 安装,安装后可以直接执行 `rdp`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pipx install livsyn-rdp-cli
|
|
15
|
+
rdp --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
也可以安装到 Python 虚拟环境:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 -m venv .venv
|
|
22
|
+
.venv/bin/python -m pip install livsyn-rdp-cli
|
|
23
|
+
.venv/bin/rdp --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
升级时执行 `pipx upgrade livsyn-rdp-cli`,或在虚拟环境中执行 `.venv/bin/python -m pip install --upgrade livsyn-rdp-cli`。
|
|
27
|
+
|
|
28
|
+
从 PyPI 安装无需 GitLab 权限。需要从源码安装时,使用具有仓库读取权限的 Git 账号:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://git.lingrobotics.com/cloud_service/rdp-cli.git
|
|
32
|
+
cd rdp-cli
|
|
33
|
+
python3 -m venv .venv
|
|
34
|
+
.venv/bin/pip install .
|
|
35
|
+
.venv/bin/rdp --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
也可以直接从 GitLab 安装:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pipx install 'git+https://git.lingrobotics.com/cloud_service/rdp-cli.git'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
仓库认证使用已有 Git 凭据,不要把密码或访问令牌写进安装 URL。安装包仅包含客户端;连接设备仍需要平台签发的有效 AK/SK。
|
|
45
|
+
|
|
46
|
+
## 配置凭据
|
|
47
|
+
|
|
48
|
+
1. 由技术管理员登录 [RDP](https://rdp.lingrobotics.com),进入右上角“API 凭据”→“签发凭据”。
|
|
49
|
+
2. 选择技术员和有效期即可,名称自动生成,权限及客户范围自动带入,无需重复配置。将 AK 和仅显示一次的 SK 安全交付本人。
|
|
50
|
+
3. 技术员在运行客户端的机器上执行 `rdp configure`,按提示输入地址、AK、SK。默认地址为 `https://rdp.lingrobotics.com`;使用虚拟环境安装时执行 `.venv/bin/rdp configure`。
|
|
51
|
+
|
|
52
|
+
技术员可在平台查看和撤销本人凭据,不能自行签发、重置或扩权;重置请联系技术管理员。收回客户权限会实时生效,新增客户不会自动扩大旧凭据范围,需要签发新凭据。
|
|
53
|
+
|
|
54
|
+
SK 仅在创建时显示一次。配置程序交互读取 SK,优先使用已安装且可用的 Python keyring;否则保存到权限 `0600` 的 `~/.config/rdp/credentials.json`。可通过 `RDP_CREDENTIAL_FILE` 指定私有配置路径。不要把 SK 放入命令参数、源码、聊天或日志。
|
|
55
|
+
|
|
56
|
+
后续示例使用已加入 PATH 的 `rdp`;从仓库虚拟环境运行时,将其替换为 `.venv/bin/rdp`。
|
|
57
|
+
|
|
58
|
+
## 使用
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
rdp devices
|
|
62
|
+
rdp ssh 221
|
|
63
|
+
rdp ssh DEVICE_REF_UUID
|
|
64
|
+
rdp ssh 唯一设备名称
|
|
65
|
+
rdp sessions
|
|
66
|
+
rdp sessions close SESSION_ID
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
节点号和名称需要 `devices:read`;只有 `ssh:connect` 的凭据可以直接连接已知的稳定 `device_ref`。长期脚本优先保存稳定引用,每个窗口对应独立会话。
|
|
70
|
+
|
|
71
|
+
SSH 用户和端口由平台管理。CLI 自动签名、获取一次性票据、校验 Gateway 主机公钥并续租;退出后回收会话。网络故障按租约过期收回,不自动重跑远程命令。单个会话仍有 24 小时绝对上限。
|
|
72
|
+
|
|
73
|
+
当前支持交互 shell 和通过标准输入执行命令。检查命令示例(设备编号替换为本人有权访问的设备):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
printf 'hostname\nuptime\ndf -h\nexit\n' | rdp ssh 221
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
当前没有独立的 `rdp exec` 子命令。新模式不支持 SFTP/SCP、VS Code Remote、端口转发或 SSH 连接复用。直接使用 API 票据运行裸 SSH 不会自动续租。
|
|
80
|
+
|
|
81
|
+
## Codex 使用
|
|
82
|
+
|
|
83
|
+
在哪台机器运行 Codex,就在哪台机器安装并配置本客户端。凭据由用户在终端配置,模型无需读取或输出凭据文件。可以给 Codex 这样的指令:
|
|
84
|
+
|
|
85
|
+
> 使用本机 RDP CLI 查询授权设备,再连接设备 #221,检查主机名、运行时间和磁盘使用情况,完成后退出。凭据已经配置,不要读取或输出凭据文件。若命令不在 PATH,使用客户端虚拟环境中的 rdp 绝对路径。
|
|
86
|
+
|
|
87
|
+
## Python SDK
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from rdp_cli import Client
|
|
91
|
+
|
|
92
|
+
# 从调用端私有凭据配置取得以下值,不写进源码。
|
|
93
|
+
client = Client(origin, access_key, secret_key)
|
|
94
|
+
devices = client.request("GET", "/api/automation/v1/devices")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
SDK 负责 RFC 9421 签名、正文摘要、防重放 nonce 和 POST 幂等键。429/503 有限重试,每次重试使用新签名;SDK 不跟随重定向。创建会话的响应包含短期票据,不要直接打印或记录。
|
|
98
|
+
|
|
99
|
+
SDK 本身不启动 SSH 或续租线程;需要在线续租的交互连接使用 `rdp ssh`。CLI 已封装接口调用,技术员无需查看接口文档。完整契约见仅技术管理员和超管可访问的 [RDP OpenAPI 文档](https://rdp.lingrobotics.com/api-docs/automation)。
|
|
100
|
+
|
|
101
|
+
## 开发与打包
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 -m venv .venv
|
|
105
|
+
.venv/bin/pip install . build
|
|
106
|
+
.venv/bin/python -m unittest discover -s tests
|
|
107
|
+
.venv/bin/python -m build
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
构建后,使用 `python -m twine check --strict dist/*` 检查分发包,再通过已配置的 PyPI 发布凭据上传。发布 Token 不写入仓库或命令参数。
|
|
111
|
+
|
|
112
|
+
运行时代码最初取自 `cloud_service/rdp` 的提交 `e2766b6b4f0d313e3d2b45810726c7a437701c6a`,目录为 `cli/`。独立仓库不包含 RDP 后端、私有配置或运行时凭据。
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livsyn-rdp-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RDP signed API client and managed SSH connection
|
|
5
|
+
Home-page: https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
6
|
+
Author: Livsyn Robotics
|
|
7
|
+
Project-URL: Source, https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: System :: Networking
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: http-message-signatures==2.0.0
|
|
17
|
+
Requires-Dist: typing-extensions==4.16.0
|
|
18
|
+
|
|
19
|
+
# RDP CLI
|
|
20
|
+
|
|
21
|
+
通过个人 AK/SK 查询授权设备、建立 SSH 连接并自动续租。Python 包名为 `livsyn-rdp-cli`,安装后的命令为 `rdp`。
|
|
22
|
+
|
|
23
|
+
客户端从 RDP 仓库拆出,后续在此独立维护。服务端采用管理员代发模式:技术管理员为技术员签发凭据,技术员使用本人凭据;超管保留管理权限。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
需要 Python 3.10+ 和本机 OpenSSH。支持 Linux/macOS,Windows 使用 WSL。
|
|
28
|
+
|
|
29
|
+
推荐使用 pipx 安装,安装后可以直接执行 `rdp`:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install livsyn-rdp-cli
|
|
33
|
+
rdp --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
也可以安装到 Python 虚拟环境:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python3 -m venv .venv
|
|
40
|
+
.venv/bin/python -m pip install livsyn-rdp-cli
|
|
41
|
+
.venv/bin/rdp --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
升级时执行 `pipx upgrade livsyn-rdp-cli`,或在虚拟环境中执行 `.venv/bin/python -m pip install --upgrade livsyn-rdp-cli`。
|
|
45
|
+
|
|
46
|
+
从 PyPI 安装无需 GitLab 权限。需要从源码安装时,使用具有仓库读取权限的 Git 账号:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://git.lingrobotics.com/cloud_service/rdp-cli.git
|
|
50
|
+
cd rdp-cli
|
|
51
|
+
python3 -m venv .venv
|
|
52
|
+
.venv/bin/pip install .
|
|
53
|
+
.venv/bin/rdp --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
也可以直接从 GitLab 安装:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pipx install 'git+https://git.lingrobotics.com/cloud_service/rdp-cli.git'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
仓库认证使用已有 Git 凭据,不要把密码或访问令牌写进安装 URL。安装包仅包含客户端;连接设备仍需要平台签发的有效 AK/SK。
|
|
63
|
+
|
|
64
|
+
## 配置凭据
|
|
65
|
+
|
|
66
|
+
1. 由技术管理员登录 [RDP](https://rdp.lingrobotics.com),进入右上角“API 凭据”→“签发凭据”。
|
|
67
|
+
2. 选择技术员和有效期即可,名称自动生成,权限及客户范围自动带入,无需重复配置。将 AK 和仅显示一次的 SK 安全交付本人。
|
|
68
|
+
3. 技术员在运行客户端的机器上执行 `rdp configure`,按提示输入地址、AK、SK。默认地址为 `https://rdp.lingrobotics.com`;使用虚拟环境安装时执行 `.venv/bin/rdp configure`。
|
|
69
|
+
|
|
70
|
+
技术员可在平台查看和撤销本人凭据,不能自行签发、重置或扩权;重置请联系技术管理员。收回客户权限会实时生效,新增客户不会自动扩大旧凭据范围,需要签发新凭据。
|
|
71
|
+
|
|
72
|
+
SK 仅在创建时显示一次。配置程序交互读取 SK,优先使用已安装且可用的 Python keyring;否则保存到权限 `0600` 的 `~/.config/rdp/credentials.json`。可通过 `RDP_CREDENTIAL_FILE` 指定私有配置路径。不要把 SK 放入命令参数、源码、聊天或日志。
|
|
73
|
+
|
|
74
|
+
后续示例使用已加入 PATH 的 `rdp`;从仓库虚拟环境运行时,将其替换为 `.venv/bin/rdp`。
|
|
75
|
+
|
|
76
|
+
## 使用
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
rdp devices
|
|
80
|
+
rdp ssh 221
|
|
81
|
+
rdp ssh DEVICE_REF_UUID
|
|
82
|
+
rdp ssh 唯一设备名称
|
|
83
|
+
rdp sessions
|
|
84
|
+
rdp sessions close SESSION_ID
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
节点号和名称需要 `devices:read`;只有 `ssh:connect` 的凭据可以直接连接已知的稳定 `device_ref`。长期脚本优先保存稳定引用,每个窗口对应独立会话。
|
|
88
|
+
|
|
89
|
+
SSH 用户和端口由平台管理。CLI 自动签名、获取一次性票据、校验 Gateway 主机公钥并续租;退出后回收会话。网络故障按租约过期收回,不自动重跑远程命令。单个会话仍有 24 小时绝对上限。
|
|
90
|
+
|
|
91
|
+
当前支持交互 shell 和通过标准输入执行命令。检查命令示例(设备编号替换为本人有权访问的设备):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
printf 'hostname\nuptime\ndf -h\nexit\n' | rdp ssh 221
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
当前没有独立的 `rdp exec` 子命令。新模式不支持 SFTP/SCP、VS Code Remote、端口转发或 SSH 连接复用。直接使用 API 票据运行裸 SSH 不会自动续租。
|
|
98
|
+
|
|
99
|
+
## Codex 使用
|
|
100
|
+
|
|
101
|
+
在哪台机器运行 Codex,就在哪台机器安装并配置本客户端。凭据由用户在终端配置,模型无需读取或输出凭据文件。可以给 Codex 这样的指令:
|
|
102
|
+
|
|
103
|
+
> 使用本机 RDP CLI 查询授权设备,再连接设备 #221,检查主机名、运行时间和磁盘使用情况,完成后退出。凭据已经配置,不要读取或输出凭据文件。若命令不在 PATH,使用客户端虚拟环境中的 rdp 绝对路径。
|
|
104
|
+
|
|
105
|
+
## Python SDK
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from rdp_cli import Client
|
|
109
|
+
|
|
110
|
+
# 从调用端私有凭据配置取得以下值,不写进源码。
|
|
111
|
+
client = Client(origin, access_key, secret_key)
|
|
112
|
+
devices = client.request("GET", "/api/automation/v1/devices")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
SDK 负责 RFC 9421 签名、正文摘要、防重放 nonce 和 POST 幂等键。429/503 有限重试,每次重试使用新签名;SDK 不跟随重定向。创建会话的响应包含短期票据,不要直接打印或记录。
|
|
116
|
+
|
|
117
|
+
SDK 本身不启动 SSH 或续租线程;需要在线续租的交互连接使用 `rdp ssh`。CLI 已封装接口调用,技术员无需查看接口文档。完整契约见仅技术管理员和超管可访问的 [RDP OpenAPI 文档](https://rdp.lingrobotics.com/api-docs/automation)。
|
|
118
|
+
|
|
119
|
+
## 开发与打包
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
python3 -m venv .venv
|
|
123
|
+
.venv/bin/pip install . build
|
|
124
|
+
.venv/bin/python -m unittest discover -s tests
|
|
125
|
+
.venv/bin/python -m build
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
构建后,使用 `python -m twine check --strict dist/*` 检查分发包,再通过已配置的 PyPI 发布凭据上传。发布 Token 不写入仓库或命令参数。
|
|
129
|
+
|
|
130
|
+
运行时代码最初取自 `cloud_service/rdp` 的提交 `e2766b6b4f0d313e3d2b45810726c7a437701c6a`,目录为 `cli/`。独立仓库不包含 RDP 后端、私有配置或运行时凭据。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.cfg
|
|
4
|
+
livsyn_rdp_cli.egg-info/PKG-INFO
|
|
5
|
+
livsyn_rdp_cli.egg-info/SOURCES.txt
|
|
6
|
+
livsyn_rdp_cli.egg-info/dependency_links.txt
|
|
7
|
+
livsyn_rdp_cli.egg-info/entry_points.txt
|
|
8
|
+
livsyn_rdp_cli.egg-info/requires.txt
|
|
9
|
+
livsyn_rdp_cli.egg-info/top_level.txt
|
|
10
|
+
rdp_cli/__init__.py
|
|
11
|
+
rdp_cli/client.py
|
|
12
|
+
rdp_cli/main.py
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rdp_cli
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from urllib.error import HTTPError
|
|
10
|
+
from urllib.parse import urlencode, urlsplit
|
|
11
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
12
|
+
|
|
13
|
+
from http_message_signatures import HTTPMessageSigner, HTTPSignatureKeyResolver, algorithms, http_sfv
|
|
14
|
+
|
|
15
|
+
BASE = '/api/automation/v1'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiError(Exception):
|
|
19
|
+
def __init__(self, code, message, status=0, details=None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code, self.status, self.details = code, status, details or {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
25
|
+
def redirect_request(self, *args, **kwargs):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KeyResolver(HTTPSignatureKeyResolver):
|
|
30
|
+
def __init__(self, key):
|
|
31
|
+
self.key = key
|
|
32
|
+
|
|
33
|
+
def resolve_private_key(self, key_id):
|
|
34
|
+
return self.key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
def __init__(self, origin, access_key, secret_key):
|
|
39
|
+
parsed = urlsplit(origin)
|
|
40
|
+
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('', '/'):
|
|
41
|
+
raise ValueError('平台地址必须是 HTTPS origin')
|
|
42
|
+
self.origin, self.access_key = origin.rstrip('/'), access_key
|
|
43
|
+
key = base64.urlsafe_b64decode(secret_key + '=')
|
|
44
|
+
if len(key) != 32:
|
|
45
|
+
raise ValueError('SK 格式不正确')
|
|
46
|
+
self.signer = HTTPMessageSigner(signature_algorithm=algorithms.HMAC_SHA256, key_resolver=KeyResolver(key))
|
|
47
|
+
self.opener = build_opener(NoRedirect())
|
|
48
|
+
|
|
49
|
+
def signed_request(self, method, path, body, idempotency_key=None):
|
|
50
|
+
headers = {'Accept': 'application/json', 'Content-Digest': str(http_sfv.Dictionary({'sha-256': hashlib.sha256(body).digest()}))}
|
|
51
|
+
components = ['@method', '@target-uri', 'content-digest']
|
|
52
|
+
if method == 'POST':
|
|
53
|
+
headers.update({'Content-Type': 'application/json', 'Idempotency-Key': idempotency_key})
|
|
54
|
+
components += ['content-type', 'idempotency-key']
|
|
55
|
+
message = SimpleNamespace(method=method, url=self.origin + path, headers=headers)
|
|
56
|
+
created = datetime.now(timezone.utc)
|
|
57
|
+
self.signer.sign(message, key_id=self.access_key, created=created, expires=created + timedelta(seconds=60),
|
|
58
|
+
nonce=secrets.token_urlsafe(18), label='rdp', tag='rdp-automation-v1', covered_component_ids=components)
|
|
59
|
+
return Request(message.url, data=body if method == 'POST' else None, method=method, headers=message.headers)
|
|
60
|
+
|
|
61
|
+
def request(self, method, path, payload=None, query=None, *, deadline=None, idempotency_key=None):
|
|
62
|
+
if not path.startswith(BASE + '/'):
|
|
63
|
+
raise ValueError('SDK 仅调用已授权的自动化 API')
|
|
64
|
+
if query:
|
|
65
|
+
path += '?' + urlencode({key: str(value).lower() if isinstance(value, bool) else value for key, value in query.items() if value is not None})
|
|
66
|
+
body = json.dumps(payload or {}, ensure_ascii=False, separators=(',', ':')).encode() if method == 'POST' else b''
|
|
67
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
68
|
+
for attempt in range(3):
|
|
69
|
+
remaining = deadline - time.monotonic() if deadline is not None else 30
|
|
70
|
+
if remaining <= 0:
|
|
71
|
+
raise ApiError('lease_expired', '连接租约已到期')
|
|
72
|
+
request = self.signed_request(method, path, body, key)
|
|
73
|
+
try:
|
|
74
|
+
with self.opener.open(request, timeout=min(10, remaining)) as response:
|
|
75
|
+
raw = response.read(2 * 1024 * 1024 + 1)
|
|
76
|
+
if len(raw) > 2 * 1024 * 1024:
|
|
77
|
+
raise ApiError('invalid_response', 'API 响应过大')
|
|
78
|
+
return json.loads(raw)
|
|
79
|
+
except HTTPError as error:
|
|
80
|
+
try:
|
|
81
|
+
result = json.loads(error.read(65536)).get('error', {})
|
|
82
|
+
except (ValueError, AttributeError):
|
|
83
|
+
result = {}
|
|
84
|
+
if not isinstance(result, dict):
|
|
85
|
+
result = {}
|
|
86
|
+
if error.code not in (429, 503) or attempt == 2:
|
|
87
|
+
raise ApiError(result.get('code', 'http_error'), result.get('message', f'HTTP {error.code}'), error.code, result.get('details')) from None
|
|
88
|
+
try:
|
|
89
|
+
delay = min(5, max(1, int(error.headers.get('Retry-After', '1'))))
|
|
90
|
+
except ValueError:
|
|
91
|
+
delay = 1
|
|
92
|
+
if deadline is not None:
|
|
93
|
+
delay = min(delay, max(0, deadline - time.monotonic()))
|
|
94
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import base64
|
|
3
|
+
import getpass
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from .client import ApiError, BASE, Client
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path():
|
|
21
|
+
return Path(os.environ.get('RDP_CREDENTIAL_FILE', str(Path.home() / '.config/rdp/credentials.json')))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure():
|
|
25
|
+
origin = input('RDP 地址 [https://rdp.lingrobotics.com]: ').strip() or 'https://rdp.lingrobotics.com'
|
|
26
|
+
access = input('Access Key: ').strip()
|
|
27
|
+
secret = getpass.getpass('Secret Key: ').strip()
|
|
28
|
+
client = Client(origin, access, secret)
|
|
29
|
+
client.request('GET', BASE + '/me')
|
|
30
|
+
path = config_path()
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
32
|
+
if path.is_symlink():
|
|
33
|
+
raise ValueError('凭据文件不能为符号链接')
|
|
34
|
+
value = {'origin': origin, 'access_key': access}
|
|
35
|
+
try:
|
|
36
|
+
import keyring
|
|
37
|
+
keyring.set_password('livsyn-rdp:' + origin, access, secret)
|
|
38
|
+
value['keyring'] = True
|
|
39
|
+
except (ImportError, RuntimeError):
|
|
40
|
+
value['secret_key'] = secret
|
|
41
|
+
fd, temporary = tempfile.mkstemp(prefix='.credentials-', dir=path.parent)
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, 'w') as stream:
|
|
44
|
+
json.dump(value, stream)
|
|
45
|
+
os.replace(temporary, path)
|
|
46
|
+
finally:
|
|
47
|
+
if os.path.exists(temporary):
|
|
48
|
+
os.unlink(temporary)
|
|
49
|
+
print('凭据已验证并保存。')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_client():
|
|
53
|
+
path = config_path()
|
|
54
|
+
if path.is_symlink() or path.stat().st_mode & 0o077:
|
|
55
|
+
raise ValueError('凭据文件必须为本人可读写的 0600 普通文件')
|
|
56
|
+
value = json.loads(path.read_text())
|
|
57
|
+
if value.get('keyring'):
|
|
58
|
+
import keyring
|
|
59
|
+
secret = keyring.get_password('livsyn-rdp:' + value['origin'], value['access_key'])
|
|
60
|
+
else:
|
|
61
|
+
secret = value['secret_key']
|
|
62
|
+
return Client(value['origin'], value['access_key'], secret or '')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def timestamp(value):
|
|
66
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).timestamp()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def write_known_hosts(gateway, path):
|
|
70
|
+
host, port = gateway['host'], gateway['port']
|
|
71
|
+
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,252}', host) or type(port) is not int or not 1 <= port <= 65535:
|
|
72
|
+
raise ValueError('Gateway 地址不正确')
|
|
73
|
+
lines = []
|
|
74
|
+
for key in gateway['host_keys']:
|
|
75
|
+
parts = key['public_key'].split()
|
|
76
|
+
if len(parts) < 2 or parts[0] != key['algorithm']:
|
|
77
|
+
raise ValueError('Gateway 主机公钥不正确')
|
|
78
|
+
raw = base64.b64decode(parts[1], validate=True)
|
|
79
|
+
fingerprint = 'SHA256:' + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip('=')
|
|
80
|
+
if fingerprint != key['fingerprint'].rstrip('='):
|
|
81
|
+
raise ValueError('Gateway 公钥指纹不匹配')
|
|
82
|
+
lines.append(f'[{host}]:{port} {parts[0]} {parts[1]}')
|
|
83
|
+
if not lines:
|
|
84
|
+
raise ValueError('Gateway 没有可信主机公钥')
|
|
85
|
+
path.write_text('\n'.join(lines) + '\n')
|
|
86
|
+
path.chmod(0o600)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_device(client, selector):
|
|
90
|
+
import uuid
|
|
91
|
+
try:
|
|
92
|
+
device_ref = str(uuid.UUID(selector))
|
|
93
|
+
except ValueError:
|
|
94
|
+
device_ref = None
|
|
95
|
+
if device_ref:
|
|
96
|
+
return {'device_ref': device_ref}
|
|
97
|
+
query = {'node_id': selector} if selector.isdigit() else {'search': selector}
|
|
98
|
+
result = client.request('GET', BASE + '/devices', query=query)
|
|
99
|
+
if result['total'] != 1:
|
|
100
|
+
raise ValueError('没有唯一匹配设备,请通过 rdp devices 查询并使用 device_ref 或精确节点号')
|
|
101
|
+
return result['items'][0]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ssh(client, selector):
|
|
105
|
+
device = resolve_device(client, selector)
|
|
106
|
+
created = client.request('POST', BASE + '/ssh/sessions', {'device_ref': device['device_ref'], 'label': socket.gethostname()[:80]})
|
|
107
|
+
session_id = created['session']['id']
|
|
108
|
+
stop, failures = threading.Event(), []
|
|
109
|
+
process = None
|
|
110
|
+
|
|
111
|
+
def supervise():
|
|
112
|
+
lease_deadline = time.monotonic() + 30
|
|
113
|
+
warned = False
|
|
114
|
+
while not stop.is_set():
|
|
115
|
+
try:
|
|
116
|
+
state = client.request('GET', BASE + '/ssh/sessions/' + session_id, deadline=lease_deadline)
|
|
117
|
+
if state['state'] in ('pending', 'connecting'):
|
|
118
|
+
stop.wait(1)
|
|
119
|
+
continue
|
|
120
|
+
if state['state'] != 'active':
|
|
121
|
+
raise ApiError('session_ended', '会话已结束:' + str(state.get('close_reason') or state['state']))
|
|
122
|
+
lease_deadline = time.monotonic() + max(0, timestamp(state['lease_expires_at']) - time.time())
|
|
123
|
+
renewed = client.request('POST', BASE + '/ssh/sessions/' + session_id + '/renew', {}, deadline=lease_deadline)
|
|
124
|
+
lease_deadline = time.monotonic() + max(0, timestamp(renewed['lease_expires_at']) - time.time())
|
|
125
|
+
if renewed['identity_status_mode'] == 'grace' and not warned:
|
|
126
|
+
print('SSO 状态刷新故障,连接进入有限宽限期:' + renewed['identity_grace_expires_at'], file=sys.stderr)
|
|
127
|
+
warned = True
|
|
128
|
+
if timestamp(renewed['expires_at']) - time.time() <= 300:
|
|
129
|
+
print('当前 SSH 会话距绝对上限不足 5 分钟,请保存工作。', file=sys.stderr)
|
|
130
|
+
stop.wait(min(renewed['renew_interval_seconds'], max(0, lease_deadline - time.monotonic()) / 3))
|
|
131
|
+
except Exception as error:
|
|
132
|
+
failures.append(str(error))
|
|
133
|
+
stop.set()
|
|
134
|
+
if process and process.poll() is None:
|
|
135
|
+
process.terminate()
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
admission = created['admission']
|
|
140
|
+
if not re.fullmatch(r'rdp_api_[A-Za-z0-9_-]+', admission['username']):
|
|
141
|
+
raise ValueError('连接票据格式不正确')
|
|
142
|
+
with tempfile.TemporaryDirectory(prefix='rdp-ssh-') as directory:
|
|
143
|
+
hosts = Path(directory) / 'known_hosts'
|
|
144
|
+
write_known_hosts(admission['gateway'], hosts)
|
|
145
|
+
args = ['ssh', '-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'ControlPersist=no',
|
|
146
|
+
'-o', 'ClearAllForwardings=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
147
|
+
'-o', 'UserKnownHostsFile=' + str(hosts), '-o', 'GlobalKnownHostsFile=/dev/null',
|
|
148
|
+
'-o', 'KnownHostsCommand=none', '-o', 'PermitLocalCommand=no',
|
|
149
|
+
'-o', 'ProxyCommand=none', '-o', 'ProxyJump=none', '-o', 'ForwardAgent=no', '-o', 'ForwardX11=no',
|
|
150
|
+
'-p', str(admission['gateway']['port']), '-l', admission['username'], admission['gateway']['host']]
|
|
151
|
+
worker = threading.Thread(target=supervise, daemon=True)
|
|
152
|
+
worker.start()
|
|
153
|
+
process = subprocess.Popen(args)
|
|
154
|
+
if stop.is_set():
|
|
155
|
+
process.terminate()
|
|
156
|
+
code = process.wait()
|
|
157
|
+
stop.set()
|
|
158
|
+
worker.join(timeout=12)
|
|
159
|
+
if failures:
|
|
160
|
+
print(failures[0], file=sys.stderr)
|
|
161
|
+
return code
|
|
162
|
+
finally:
|
|
163
|
+
stop.set()
|
|
164
|
+
try:
|
|
165
|
+
client.request('POST', BASE + '/ssh/sessions/' + session_id + '/close', {})
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main():
|
|
171
|
+
parser = argparse.ArgumentParser(description='RDP 自动化设备连接')
|
|
172
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
173
|
+
commands.add_parser('configure')
|
|
174
|
+
commands.add_parser('devices')
|
|
175
|
+
commands.add_parser('sessions').add_argument('close', nargs='*', metavar='[close SESSION_ID]')
|
|
176
|
+
commands.add_parser('ssh').add_argument('device')
|
|
177
|
+
args = parser.parse_args()
|
|
178
|
+
try:
|
|
179
|
+
if args.command == 'configure':
|
|
180
|
+
configure()
|
|
181
|
+
return
|
|
182
|
+
client = load_client()
|
|
183
|
+
if args.command == 'ssh':
|
|
184
|
+
sys.exit(ssh(client, args.device))
|
|
185
|
+
if args.command == 'sessions' and args.close:
|
|
186
|
+
if len(args.close) != 2 or args.close[0] != 'close':
|
|
187
|
+
parser.error('用法:rdp sessions close SESSION_ID')
|
|
188
|
+
value = client.request('POST', BASE + '/ssh/sessions/' + args.close[1] + '/close', {})
|
|
189
|
+
else:
|
|
190
|
+
value = client.request('GET', BASE + ('/devices' if args.command == 'devices' else '/ssh/sessions'))
|
|
191
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
192
|
+
except (ApiError, ValueError, OSError, KeyError) as error:
|
|
193
|
+
print(str(error), file=sys.stderr)
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == '__main__':
|
|
198
|
+
main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
name = livsyn-rdp-cli
|
|
3
|
+
version = 0.1.0
|
|
4
|
+
description = RDP signed API client and managed SSH connection
|
|
5
|
+
author = Livsyn Robotics
|
|
6
|
+
long_description = file: README.md
|
|
7
|
+
long_description_content_type = text/markdown
|
|
8
|
+
url = https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
9
|
+
project_urls =
|
|
10
|
+
Source = https://git.lingrobotics.com/cloud_service/rdp-cli
|
|
11
|
+
classifiers =
|
|
12
|
+
Environment :: Console
|
|
13
|
+
Operating System :: POSIX :: Linux
|
|
14
|
+
Operating System :: MacOS
|
|
15
|
+
Programming Language :: Python :: 3
|
|
16
|
+
Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Topic :: System :: Networking
|
|
18
|
+
|
|
19
|
+
[options]
|
|
20
|
+
packages = find:
|
|
21
|
+
python_requires = >=3.10
|
|
22
|
+
install_requires =
|
|
23
|
+
http-message-signatures==2.0.0
|
|
24
|
+
typing-extensions==4.16.0
|
|
25
|
+
|
|
26
|
+
[options.packages.find]
|
|
27
|
+
include = rdp_cli*
|
|
28
|
+
|
|
29
|
+
[options.entry_points]
|
|
30
|
+
console_scripts =
|
|
31
|
+
rdp = rdp_cli.main:main
|
|
32
|
+
|
|
33
|
+
[egg_info]
|
|
34
|
+
tag_build =
|
|
35
|
+
tag_date = 0
|
|
36
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import Mock, patch
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
|
|
12
|
+
from http_message_signatures import HTTPMessageVerifier, HTTPSignatureKeyResolver, algorithms
|
|
13
|
+
from http_message_signatures.structures import CaseInsensitiveDict
|
|
14
|
+
|
|
15
|
+
from rdp_cli.client import ApiError, BASE, Client
|
|
16
|
+
from rdp_cli.main import load_client, resolve_device
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FixtureKey(HTTPSignatureKeyResolver):
|
|
20
|
+
def resolve_public_key(self, key_id):
|
|
21
|
+
return b't' * 32
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClientTest(unittest.TestCase):
|
|
25
|
+
def setUp(self):
|
|
26
|
+
self.client = Client('https://rdp.test', 'rdp_ak_TEST_ONLY', base64.urlsafe_b64encode(b't' * 32).decode().rstrip('='))
|
|
27
|
+
|
|
28
|
+
def verify(self, request):
|
|
29
|
+
message = SimpleNamespace(method=request.method, url=request.full_url, headers=CaseInsensitiveDict(dict(request.header_items())))
|
|
30
|
+
verifier = HTTPMessageVerifier(signature_algorithm=algorithms.HMAC_SHA256, key_resolver=FixtureKey())
|
|
31
|
+
verifier.verify(message, max_age=timedelta(seconds=90), expect_label='rdp', expect_tag='rdp-automation-v1')
|
|
32
|
+
|
|
33
|
+
def test_get_signature_preserves_encoded_target(self):
|
|
34
|
+
request = self.client.signed_request('GET', BASE + '/devices?search=a%2Fb&online=true', b'')
|
|
35
|
+
self.verify(request)
|
|
36
|
+
self.assertTrue(request.full_url.endswith('search=a%2Fb&online=true'))
|
|
37
|
+
|
|
38
|
+
def test_post_signature_binds_idempotency_key(self):
|
|
39
|
+
request = self.client.signed_request('POST', BASE + '/ssh/sessions', b'{}', 'test-idempotency-key')
|
|
40
|
+
self.verify(request)
|
|
41
|
+
request.add_header('Idempotency-Key', 'tampered')
|
|
42
|
+
with self.assertRaises(Exception):
|
|
43
|
+
self.verify(request)
|
|
44
|
+
|
|
45
|
+
def test_transient_retry_preserves_idempotency_and_changes_signature(self):
|
|
46
|
+
response = Mock()
|
|
47
|
+
response.__enter__ = Mock(return_value=response)
|
|
48
|
+
response.__exit__ = Mock(return_value=False)
|
|
49
|
+
response.read.return_value = b'{"state":"pending"}'
|
|
50
|
+
failure = HTTPError('https://rdp.test', 503, 'Unavailable', {}, BytesIO(b'{"error":{"code":"unavailable"}}'))
|
|
51
|
+
with patch.object(self.client.opener, 'open', side_effect=[failure, response]) as opened, patch('rdp_cli.client.time.sleep'):
|
|
52
|
+
self.assertEqual(self.client.request('POST', BASE + '/ssh/sessions', {}, idempotency_key='retry-key'), {'state': 'pending'})
|
|
53
|
+
first, second = [call.args[0] for call in opened.call_args_list]
|
|
54
|
+
for request in (first, second):
|
|
55
|
+
self.verify(request)
|
|
56
|
+
self.assertEqual(request.get_header('Idempotency-key'), 'retry-key')
|
|
57
|
+
self.assertNotEqual(first.get_header('Signature-input'), second.get_header('Signature-input'))
|
|
58
|
+
|
|
59
|
+
def test_permission_denial_is_not_retried(self):
|
|
60
|
+
failure = HTTPError('https://rdp.test', 403, 'Forbidden', {}, BytesIO(b'{"error":{"code":"scope_denied","message":"denied"}}'))
|
|
61
|
+
with patch.object(self.client.opener, 'open', side_effect=failure) as opened:
|
|
62
|
+
with self.assertRaises(ApiError) as denied:
|
|
63
|
+
self.client.request('GET', BASE + '/devices')
|
|
64
|
+
self.assertEqual(denied.exception.status, 403)
|
|
65
|
+
opened.assert_called_once()
|
|
66
|
+
|
|
67
|
+
def test_known_device_ref_does_not_require_inventory_scope(self):
|
|
68
|
+
device_ref = '00000000-0000-4000-8000-000000000001'
|
|
69
|
+
with patch.object(self.client, 'request', side_effect=AssertionError('inventory lookup')):
|
|
70
|
+
self.assertEqual(resolve_device(self.client, device_ref), {'device_ref': device_ref})
|
|
71
|
+
|
|
72
|
+
def test_credentials_require_private_file_mode(self):
|
|
73
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
74
|
+
path = Path(folder) / 'credentials.json'
|
|
75
|
+
path.write_text(json.dumps({'origin': self.client.origin, 'access_key': self.client.access_key,
|
|
76
|
+
'secret_key': base64.urlsafe_b64encode(b't' * 32).decode().rstrip('=')}))
|
|
77
|
+
with patch('rdp_cli.main.config_path', return_value=path):
|
|
78
|
+
path.chmod(0o644)
|
|
79
|
+
with self.assertRaises(ValueError):
|
|
80
|
+
load_client()
|
|
81
|
+
path.chmod(0o600)
|
|
82
|
+
self.assertEqual(load_client().access_key, self.client.access_key)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == '__main__':
|
|
86
|
+
unittest.main()
|