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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ rdp = rdp_cli.main:main
@@ -0,0 +1,2 @@
1
+ http-message-signatures==2.0.0
2
+ typing-extensions==4.16.0
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,3 @@
1
+ from .client import Client, ApiError
2
+
3
+ __all__ = ['Client', 'ApiError']
@@ -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()