dsh-plugin-mobile-gateway 0.6.0 → 0.6.3
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.
- package/PROTOCOL.md +18 -1
- package/README.md +89 -194
- package/bin/setup-ip.mjs +116 -0
- package/helper/dsh_mobile_gateway_helper.py +227 -0
- package/lib/client.js +64 -0
- package/lib/index.mjs +118 -1
- package/package.json +3 -2
- package/docs/public-ip-deployment-guide.md +0 -135
package/PROTOCOL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh Mobile Gateway — WebSocket 协议参考
|
|
2
2
|
|
|
3
|
-
移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.
|
|
3
|
+
移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送文字和图片、处理 Human-in-the-loop 提问、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.6.3)。
|
|
4
4
|
|
|
5
5
|
- **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
|
|
6
6
|
- **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
|
|
@@ -405,6 +405,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
|
|
|
405
405
|
| `workspaces` | — | 全部工作区(含每个的 `sessionIds`) |
|
|
406
406
|
| `workspace-create` | `path` | 对**已存在目录**创建工作区(已归属→`created:false` 幂等) |
|
|
407
407
|
| `directories` | `path?` | 浏览 server 目录(缺省 = home);`crumbs` 面包屑 + `entries`(含 `hidden` 标记) |
|
|
408
|
+
| `directory-create` | `path`, `name` | 在父目录下创建一个子文件夹 |
|
|
408
409
|
|
|
409
410
|
```json
|
|
410
411
|
{ "type": "workspace-create", "path": "/Users/lichaofan/DeepseekHarnessProject" }
|
|
@@ -412,6 +413,19 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
|
|
|
412
413
|
"created": true }
|
|
413
414
|
```
|
|
414
415
|
|
|
416
|
+
### 创建文件夹
|
|
417
|
+
|
|
418
|
+
`path` 是当前父目录的绝对路径,`name` 只传新文件夹名称,不传完整目标路径:
|
|
419
|
+
|
|
420
|
+
```json
|
|
421
|
+
{ "type": "directory-create", "path": "/Users/lichaofan/DeepseekHarnessProject", "name": "Sources" }
|
|
422
|
+
→ { "kind": "directory-create", "path": "/Users/lichaofan/DeepseekHarnessProject/Sources" }
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
创建和目录浏览使用同一套宿主 Node 文件系统实现,不依赖 DSH 的 native Directory Picker,因此 macOS native 模式也可远程创建目录。父路径必须是绝对路径且必须指向真实存在的目录;名称去除首尾空白后不能为空、`.`、`..`,也不能包含 `/` 或 `\\`。
|
|
426
|
+
|
|
427
|
+
创建成功后,iOS 应重新发送对应父目录的 `directories` 请求来刷新列表。请求格式、相对父路径或非法名称返回 `bad-request`;目标已存在返回 `directory-exists`;父目录不存在、不是目录或其他文件系统失败返回 `directory-create-failed`。此操作只创建文件夹,不会自动注册工作区;如果要把新目录作为工作区,再使用返回的 `path` 调用 `workspace-create`。
|
|
428
|
+
|
|
415
429
|
---
|
|
416
430
|
|
|
417
431
|
## 7. 模型与思考等级
|
|
@@ -547,6 +561,7 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
|
|
|
547
561
|
- `/ws/mobile` 的移动网关默认关闭;本机 WebUI 手动开启后,若 5 分钟内没有设备成功连接会自动关闭
|
|
548
562
|
- 网关开启后仍要求已配对设备凭证;不要把 `requireAuth` 设为 `false` 后暴露到网络
|
|
549
563
|
- `/mgw/*` 是配对/吊销管理面,默认只允许本机访问;公网代理只应转发 `/ws/mobile`
|
|
564
|
+
- `/mgw/public-setup` 仅供本机 WebUI 调用,通过受限 Unix Socket 请求 root Helper;Helper 只接受状态查询以及“公网 IPv4 + 当前 DSH 端口”的固定 Nginx 配置操作,不接受命令或文件路径
|
|
550
565
|
- DSH HTTP Server 本身没有 TLS、认证或 Origin policy;公网必须使用 TLS 反向代理和 `wss://`
|
|
551
566
|
- 长期 token 只保存在 iOS Keychain;服务端磁盘仅保存摘要
|
|
552
567
|
- `set-default` / `save-default-model` 是全局写操作,客户端 UI 应加确认
|
|
@@ -574,6 +589,8 @@ iOS 用 `Data(base64Encoded:)` 解码并按 `attachment.mediaType` 渲染,建
|
|
|
574
589
|
| v0.3.0 | 默认设备鉴权;一次性二维码配对;摘要化凭证存储;WebUI 设备面板;在线状态和即时吊销 |
|
|
575
590
|
| v0.5.0 | Human-in-the-loop:转发 API Gateway question 请求、整批回答/取消、重连重放与多端状态收敛 |
|
|
576
591
|
| v0.6.0 | DSH 0.1.1 图片:WebSocket Base64 上传、实时图片引用、历史附件按会话安全读取 |
|
|
592
|
+
| v0.6.3 | macOS native picker 兼容:目录创建改用与目录浏览一致的宿主文件系统实现,并补齐路径、名称和错误码校验 |
|
|
593
|
+
| v0.6.2 | 目录创建:通过 API Gateway `host.createDirectory` 在工作区目录下创建子文件夹 |
|
|
577
594
|
|
|
578
595
|
---
|
|
579
596
|
|
package/README.md
CHANGED
|
@@ -1,279 +1,174 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="docs/assets/whale-girl-ios-app-promo-16x9.png" alt="
|
|
2
|
+
<img src="docs/assets/whale-girl-ios-app-promo-16x9.png" alt="DeepSeek Harness Mobile 与移动网关" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# dsh-plugin-mobile-gateway
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
让 iPhone 通过经过设备鉴权的 WebSocket 连接 DeepSeek Harness。安装后,Harness WebUI 左侧边栏会出现“移动设备”入口,可直接开启网关、生成配对二维码和管理可信设备。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
- WebSocket:`/ws/mobile`
|
|
10
|
+
- 局域网:`ws://<局域网 IP>:3081/ws/mobile`
|
|
11
|
+
- 公网:`wss://<公网 IP>/ws/mobile`
|
|
12
|
+
- 协议文档:[PROTOCOL.md](PROTOCOL.md)
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
- 管理入口:Harness WebUI 左侧边栏底部的“移动设备”
|
|
13
|
-
- 默认安全策略:网关默认关闭、设备鉴权默认开启、管理接口仅允许本机访问
|
|
14
|
-
- 完整通信格式:[`PROTOCOL.md`](PROTOCOL.md)
|
|
14
|
+
## 配套 iOS 客户端
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
[DeepSeek Harness Mobile](https://github.com/Clarklevis1995/dsh-mobile) 是本仓库的兄弟项目。它是面向 iOS 17+ 的 SwiftUI 原生客户端,支持工作区与会话、工作区内创建文件夹、历史和实时对话、图片、Agent 执行轨迹、Human-in-the-loop、模型与权限设置。
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
<table>
|
|
19
|
+
<tr>
|
|
20
|
+
<td width="33.33%" align="center"><img src="https://raw.githubusercontent.com/Clarklevis1995/dsh-mobile/main/Docs/images/screenshots/home.png" alt="iOS 工作区首页" width="100%"></td>
|
|
21
|
+
<td width="33.33%" align="center"><img src="https://raw.githubusercontent.com/Clarklevis1995/dsh-mobile/main/Docs/images/screenshots/conversation-dark.png" alt="iOS 深色对话界面" width="100%"></td>
|
|
22
|
+
<td width="33.33%" align="center"><img src="https://raw.githubusercontent.com/Clarklevis1995/dsh-mobile/main/Docs/images/screenshots/pairing-dark.png" alt="iOS 设备配对界面" width="100%"></td>
|
|
23
|
+
</tr>
|
|
24
|
+
<tr>
|
|
25
|
+
<td align="center"><strong>工作区首页</strong></td>
|
|
26
|
+
<td align="center"><strong>实时对话</strong></td>
|
|
27
|
+
<td align="center"><strong>设备配对</strong></td>
|
|
28
|
+
</tr>
|
|
29
|
+
</table>
|
|
19
30
|
|
|
20
|
-
|
|
31
|
+
## 安装插件
|
|
21
32
|
|
|
22
|
-
|
|
33
|
+
前提:已经安装 `dsh` CLI,并能正常启动 `dsh web`。
|
|
34
|
+
|
|
35
|
+
局域网使用只需安装插件:
|
|
23
36
|
|
|
24
37
|
```bash
|
|
25
38
|
dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
|
|
26
39
|
```
|
|
27
40
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
如果希望固定版本,可以在包名后指定版本号:
|
|
41
|
+
需要公网接入时,推荐执行统一初始化命令。它会安装/更新插件,并请求一次 sudo 权限安装系统 Helper:
|
|
31
42
|
|
|
32
43
|
```bash
|
|
33
|
-
|
|
44
|
+
npx --yes dsh-plugin-mobile-gateway@latest init
|
|
34
45
|
```
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
安装后停止并重新启动 WebUI:
|
|
37
48
|
|
|
38
49
|
```bash
|
|
39
|
-
dsh
|
|
50
|
+
dsh web
|
|
40
51
|
```
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
打开 WebUI,确认左侧边栏底部出现“移动设备”。
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
## 局域网配对
|
|
45
56
|
|
|
46
|
-
|
|
57
|
+
适用于 DSH 电脑和 iPhone 位于同一个可互访的局域网。
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
1. 打开 WebUI 的“移动设备”。
|
|
60
|
+
2. 开启“允许移动设备连接”。
|
|
61
|
+
3. 保持“设备鉴权”开启。
|
|
62
|
+
4. 确认面板显示 `ws://<电脑局域网 IP>:3081/ws/mobile`。
|
|
63
|
+
5. 填写设备名称并点击“生成配对二维码”。
|
|
64
|
+
6. 在 iOS 客户端打开“设备认证”,扫描二维码。
|
|
65
|
+
7. WebUI 的可信设备显示“在线”后即完成。
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
如果系统防火墙拦截连接,只允许私有网络访问 TCP `3081`。不要把 3081 开放到公网。
|
|
53
68
|
|
|
54
|
-
|
|
69
|
+
## 公网 IP 配对
|
|
55
70
|
|
|
56
|
-
|
|
71
|
+
适用于带固定公网 IPv4 的 Ubuntu/Debian 服务器(其他发行版本可自行尝试)。Node.js 通过 nvm 安装时,也使用下面的命令。
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
ws://<运行 DSH 的电脑局域网 IP>:3081/ws/mobile
|
|
60
|
-
```
|
|
73
|
+
### 1. 配置端口
|
|
61
74
|
|
|
62
|
-
|
|
75
|
+
从云厂商控制台复制服务器的公网 IPv4,并在安全组中放行入站 TCP `80` 和 `443`。
|
|
63
76
|
|
|
64
|
-
|
|
77
|
+
不要开放 TCP `3080` 和 `3081`!!!
|
|
78
|
+
不要开放 TCP `3080` 和 `3081`!!!
|
|
79
|
+
不要开放 TCP `3080` 和 `3081`!!!
|
|
65
80
|
|
|
66
|
-
###
|
|
81
|
+
### 2. 配置公网入口
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
执行一次初始化(已经执行过可跳过):
|
|
69
84
|
|
|
70
85
|
```bash
|
|
71
|
-
|
|
86
|
+
npx --yes dsh-plugin-mobile-gateway@latest init
|
|
72
87
|
```
|
|
73
88
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
- 安装 Nginx 和独立 Python 虚拟环境中的 Certbot
|
|
77
|
-
- 为公网 IP 申请受系统信任的短期 TLS 证书
|
|
78
|
-
- 只把 `wss://公网IP/ws/mobile` 代理到 `127.0.0.1:3080`
|
|
79
|
-
- 对普通 HTTP、WebUI、`/mgw/*` 管理接口和其他路径返回 `404`
|
|
80
|
-
- 创建每天两次运行的 systemd 证书续期任务
|
|
81
|
-
- 把公网地址写入 `/etc/dsh-mobile-gateway/public-url`,插件启动时自动读取
|
|
82
|
-
|
|
83
|
-
因此 DSH 仍然只需监听 `127.0.0.1:3080`,不需要把 3080 端口暴露到公网,也不需要 Tunnel、域名或手写 Nginx 配置。
|
|
84
|
-
|
|
85
|
-
邮箱是可选项;希望接收 Let's Encrypt 账户通知时可追加 `--email you@example.com`。如果无法从腾讯云元数据识别公网地址,或 DSH 使用了其他本地端口:
|
|
89
|
+
然后启动或重新启动:
|
|
86
90
|
|
|
87
91
|
```bash
|
|
88
|
-
|
|
89
|
-
--ip 203.0.113.10 \
|
|
90
|
-
--port 3080 \
|
|
91
|
-
--email you@example.com
|
|
92
|
+
dsh web
|
|
92
93
|
```
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
### 3. 打开远程 WebUI(使用端口转发,VSCode等IDE自带)
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
如果 WebUI 运行在远程服务器,在自己的电脑执行:
|
|
97
98
|
|
|
98
99
|
```bash
|
|
99
|
-
|
|
100
|
-
sudo npx --yes dsh-plugin-mobile-gateway remove
|
|
100
|
+
ssh -N -L <本地端口>:127.0.0.1:<DSH 实际端口> <服务器用户名>@<服务器公网 IP>
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
#### 已有域名或反向代理
|
|
106
|
-
|
|
107
|
-
打开“移动设备”面板:
|
|
108
|
-
|
|
109
|
-
1. 开启“允许移动设备连接”。
|
|
110
|
-
2. 保持“设备鉴权”开启。
|
|
111
|
-
3. 在“WebSocket 地址”中填写手机可以访问的地址。
|
|
112
|
-
|
|
113
|
-
本机浏览器调试可以使用:
|
|
114
|
-
|
|
115
|
-
```text
|
|
116
|
-
ws://127.0.0.1:3080/ws/mobile
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
真机不能使用 `127.0.0.1`,因为它在手机上指向手机自身。同一私有局域网可使用插件自动提供的 `ws://局域网IP:3081/ws/mobile`;公网连接则应通过 TLS 反向代理提供:
|
|
103
|
+
然后在本地浏览器打开:
|
|
120
104
|
|
|
121
105
|
```text
|
|
122
|
-
|
|
106
|
+
http://127.0.0.1:<本地端口>
|
|
123
107
|
```
|
|
124
108
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
### 5. 配对 iOS 客户端
|
|
109
|
+
### 4. 使用 WebUI 配对
|
|
128
110
|
|
|
129
|
-
1.
|
|
130
|
-
2.
|
|
131
|
-
3.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
5. WebUI 的可信设备列表显示“在线”,iOS 首页连接状态变为绿色,即表示连接完成。
|
|
111
|
+
1. 打开“移动设备”,在“公网接入”中填写云厂商控制台提供的公网 IPv4。
|
|
112
|
+
2. 点击“配置公网接入”。Helper 会自动使用当前 `dsh web` 端口配置 Nginx 和证书。
|
|
113
|
+
3. 开启“允许移动设备连接”,保持“设备鉴权”开启。
|
|
114
|
+
4. 填写设备名称并点击“生成配对二维码”。
|
|
115
|
+
5. iPhone 打开“设备认证”并扫描二维码。
|
|
116
|
+
6. WebUI 的可信设备显示“在线”后即完成。
|
|
136
117
|
|
|
137
|
-
|
|
118
|
+
二维码只能使用一次,并会在 5 分钟后过期;超时后在 WebUI 重新生成即可。
|
|
138
119
|
|
|
139
|
-
##
|
|
120
|
+
## 公网入口管理
|
|
140
121
|
|
|
141
|
-
|
|
122
|
+
查看状态:
|
|
142
123
|
|
|
143
|
-
|
|
144
|
-
-
|
|
145
|
-
- 加载历史消息与轨迹
|
|
146
|
-
- 从历史消息安全加载图片,并把 iOS 本地图片发送给 Agent
|
|
147
|
-
- 实时接收思考、工具调用和最终回答
|
|
148
|
-
- 向远端 Agent 发送任务
|
|
149
|
-
- 查询或切换会话模型、推理等级和访问权限
|
|
150
|
-
- 查看上下文用量与会话统计
|
|
151
|
-
|
|
152
|
-
关闭“允许移动设备连接”会立即断开移动端,但不会影响普通 Harness WebUI。网关开启后,如果默认 5 分钟内没有可信设备成功连接,会自动关闭。
|
|
153
|
-
|
|
154
|
-
## 可信设备管理
|
|
155
|
-
|
|
156
|
-
长期设备凭证只在首次配对成功时返回一次,移动端应保存在 Keychain。服务端仅保存凭证的 SHA-256 摘要:
|
|
157
|
-
|
|
158
|
-
```text
|
|
159
|
-
~/.dsh/mobile-gateway-devices.json
|
|
124
|
+
```bash
|
|
125
|
+
sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest status
|
|
160
126
|
```
|
|
161
127
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- 从可信设备列表删除该设备
|
|
165
|
-
- 立即关闭该设备现有的 WebSocket 连接
|
|
166
|
-
- 使该设备保存的长期凭证永久失效
|
|
128
|
+
移除公网入口:
|
|
167
129
|
|
|
168
|
-
|
|
130
|
+
```bash
|
|
131
|
+
sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove
|
|
132
|
+
```
|
|
169
133
|
|
|
170
|
-
##
|
|
134
|
+
## 更新插件
|
|
171
135
|
|
|
172
|
-
|
|
136
|
+
重新运行初始化命令会同时更新插件和系统 Helper:
|
|
173
137
|
|
|
174
138
|
```bash
|
|
175
|
-
|
|
176
|
-
dsh plugin --profile web add dsh-plugin-mobile-gateway
|
|
139
|
+
npx --yes dsh-plugin-mobile-gateway@latest init
|
|
177
140
|
```
|
|
178
141
|
|
|
179
|
-
|
|
142
|
+
随后停止并重新启动 `dsh web`。
|
|
180
143
|
|
|
181
|
-
##
|
|
144
|
+
## 卸载插件
|
|
182
145
|
|
|
183
146
|
```bash
|
|
184
147
|
dsh plugin --profile web remove dsh-plugin-mobile-gateway
|
|
185
148
|
```
|
|
186
149
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
卸载插件不会自动删除 `~/.dsh/mobile-gateway-devices.json`。
|
|
190
|
-
|
|
191
|
-
## 手动公网部署
|
|
192
|
-
|
|
193
|
-
不使用一键安装器时,公网入口应由 Nginx、Caddy 或其他反向代理提供 HTTPS/WSS,并且只公开 WebSocket 路由,不要公开 `/mgw` 管理接口或整个未鉴权 WebUI。
|
|
194
|
-
|
|
195
|
-
Nginx 核心配置示例:
|
|
196
|
-
|
|
197
|
-
```nginx
|
|
198
|
-
location = /ws/mobile {
|
|
199
|
-
proxy_pass http://127.0.0.1:3080;
|
|
200
|
-
proxy_http_version 1.1;
|
|
201
|
-
proxy_set_header Upgrade $http_upgrade;
|
|
202
|
-
proxy_set_header Connection "upgrade";
|
|
203
|
-
}
|
|
204
|
-
```
|
|
150
|
+
如需同时移除系统 Helper(不会删除现有 Nginx 公网配置):
|
|
205
151
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
如需把公网地址写入 profile,可在 `cordis.patch.yml` 的 `mobile-gateway` 配置段覆盖:
|
|
209
|
-
|
|
210
|
-
```yaml
|
|
211
|
-
- id: mobile-gateway
|
|
212
|
-
config:
|
|
213
|
-
path: /ws/mobile
|
|
214
|
-
gatewayEnabled: false
|
|
215
|
-
gatewayWaitTimeoutMs: 300000
|
|
216
|
-
requireAuth: true
|
|
217
|
-
adminLoopbackOnly: true
|
|
218
|
-
lanEnabled: true
|
|
219
|
-
lanHost: 0.0.0.0
|
|
220
|
-
lanPort: 3081
|
|
221
|
-
lanAdvertiseHost: ''
|
|
222
|
-
publicUrl: wss://gateway.example.com/ws/mobile
|
|
223
|
-
publicUrlFile: /etc/dsh-mobile-gateway/public-url
|
|
224
|
-
pairingTtlMs: 300000
|
|
225
|
-
allowQueryToken: false
|
|
152
|
+
```bash
|
|
153
|
+
sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest remove-helper
|
|
226
154
|
```
|
|
227
155
|
|
|
228
|
-
后置 patch 会替换整个配置段,因此覆盖时应保留所有需要的字段。
|
|
229
|
-
|
|
230
156
|
## 常见问题
|
|
231
157
|
|
|
232
|
-
| 现象 |
|
|
158
|
+
| 现象 | 处理方式 |
|
|
233
159
|
|---|---|
|
|
234
|
-
| WebUI
|
|
235
|
-
| iOS
|
|
236
|
-
| iOS
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
| Certbot 申请或续期失败 | 确认腾讯云安全组和服务器防火墙都允许入站 TCP 80/443,并确认公网 IP 没有变化 |
|
|
241
|
-
| 想检查自动续期 | 执行 `sudo systemctl status dsh-mobile-gateway-cert-renew.timer` 和 `sudo npx --yes dsh-plugin-mobile-gateway status` |
|
|
242
|
-
| 二维码无法再次使用 | 配对码设计为一次性且 5 分钟过期,重新生成即可 |
|
|
243
|
-
| 修改配置后没有生效 | 插件与 profile composition 在启动时加载,需要重启 WebUI 进程 |
|
|
244
|
-
| 需要排查服务端原因 | 查看 `/tmp/mobile-gateway.log`,其中包含连接、鉴权、查询与错误记录 |
|
|
245
|
-
|
|
246
|
-
## 安全说明
|
|
247
|
-
|
|
248
|
-
- 不建议关闭设备鉴权。该开关仅用于本机 Debug,重启后会恢复安全默认值。
|
|
249
|
-
- `/mgw/*` 默认只接受 loopback 请求,不应通过公网反向代理暴露。
|
|
250
|
-
- 长期 token 默认禁止通过 URL query 传输,避免进入代理日志、浏览器历史或监控系统。
|
|
251
|
-
- 配对二维码和长期 token 不会明文写入服务端磁盘。
|
|
160
|
+
| WebUI 没有“移动设备” | 确认安装在 `web` profile,并完整重启 `dsh web` |
|
|
161
|
+
| iOS 收到 `503` | 回到 WebUI 开启“允许移动设备连接” |
|
|
162
|
+
| iOS 收到 `401` | 在 WebUI 重新生成二维码并配对 |
|
|
163
|
+
| 公网连接超时 | 检查云安全组、服务器防火墙和 TCP `80/443` |
|
|
164
|
+
| 公网地址没有自动显示 | 确认已执行 `setup --ip <公网 IP>`,然后重启 `dsh web` |
|
|
165
|
+
| 需要查看服务端日志 | 执行 `tail -f /tmp/mobile-gateway.log` |
|
|
252
166
|
|
|
253
167
|
## 源码开发
|
|
254
168
|
|
|
255
|
-
只有需要修改插件本身时才需要源码安装:
|
|
256
|
-
|
|
257
169
|
```bash
|
|
258
170
|
dsh plugin --profile web add file:/absolute/path/to/dsh-plugin-mobile-gateway
|
|
171
|
+
npm test
|
|
259
172
|
```
|
|
260
173
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
测试命令:
|
|
264
|
-
|
|
265
|
-
```bash
|
|
266
|
-
# 鉴权、配对与吊销
|
|
267
|
-
NODE_PATH=/path/to/dsh/node_modules node test/auth.test.mjs
|
|
268
|
-
|
|
269
|
-
# mock Harness + 真实 WebSocket 客户端的完整协议链路
|
|
270
|
-
NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs
|
|
271
|
-
|
|
272
|
-
# 公网 IP 安装器的参数、Nginx 隔离与续期配置
|
|
273
|
-
node test/setup-ip.test.mjs
|
|
274
|
-
|
|
275
|
-
# 独立局域网监听、强制鉴权与管理接口隔离
|
|
276
|
-
node test/lan.test.mjs
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
版本历史与全部 WebSocket 消息类型见 [`PROTOCOL.md`](PROTOCOL.md)。
|
|
174
|
+
源码修改后需要重新安装插件并重启 `dsh web`。
|
package/bin/setup-ip.mjs
CHANGED
|
@@ -18,10 +18,17 @@ const CERTBOT_HOME = '/opt/dsh-mobile-gateway/certbot'
|
|
|
18
18
|
const CERTBOT = path.join(CERTBOT_HOME, 'bin/certbot')
|
|
19
19
|
const RENEW_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.service'
|
|
20
20
|
const RENEW_TIMER = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.timer'
|
|
21
|
+
const HELPER_SOURCE = fileURLToPath(new URL('../helper/dsh_mobile_gateway_helper.py', import.meta.url))
|
|
22
|
+
const HELPER_INSTALL = '/usr/local/libexec/dsh-mobile-gateway-helper'
|
|
23
|
+
const HELPER_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-helper.service'
|
|
24
|
+
const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
21
25
|
|
|
22
26
|
function printHelp() {
|
|
23
27
|
console.log(`Usage:
|
|
24
28
|
dsh-plugin-mobile-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
|
|
29
|
+
dsh-plugin-mobile-gateway init
|
|
30
|
+
dsh-plugin-mobile-gateway setup-helper
|
|
31
|
+
dsh-plugin-mobile-gateway remove-helper [--yes]
|
|
25
32
|
dsh-plugin-mobile-gateway status
|
|
26
33
|
dsh-plugin-mobile-gateway remove [--yes]
|
|
27
34
|
|
|
@@ -74,6 +81,111 @@ function writeManagedFile(file, content, mode = 0o644) {
|
|
|
74
81
|
fs.chmodSync(file, mode)
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
function writeInstalledHelper(source, destination) {
|
|
85
|
+
if (fs.existsSync(destination)) {
|
|
86
|
+
const existing = fs.readFileSync(destination, 'utf8')
|
|
87
|
+
if (!existing.slice(0, 256).includes(MARKER)) {
|
|
88
|
+
throw new Error(`refusing to overwrite unmanaged file: ${destination}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 })
|
|
92
|
+
const temporary = `${destination}.tmp-${process.pid}`
|
|
93
|
+
fs.copyFileSync(source, temporary)
|
|
94
|
+
fs.chmodSync(temporary, 0o755)
|
|
95
|
+
fs.renameSync(temporary, destination)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function helperService(uid) {
|
|
99
|
+
return `${MARKER}
|
|
100
|
+
[Unit]
|
|
101
|
+
Description=DSH mobile gateway privileged configuration helper
|
|
102
|
+
After=network.target
|
|
103
|
+
|
|
104
|
+
[Service]
|
|
105
|
+
Type=simple
|
|
106
|
+
ExecStart=/usr/bin/python3 ${HELPER_INSTALL} --uid ${uid} --socket ${HELPER_SOCKET}
|
|
107
|
+
Restart=on-failure
|
|
108
|
+
RestartSec=2s
|
|
109
|
+
RuntimeDirectory=dsh-mobile-gateway
|
|
110
|
+
RuntimeDirectoryMode=0755
|
|
111
|
+
NoNewPrivileges=true
|
|
112
|
+
PrivateTmp=true
|
|
113
|
+
ProtectHome=true
|
|
114
|
+
ProtectSystem=full
|
|
115
|
+
ReadWritePaths=/etc/nginx /etc/dsh-mobile-gateway /etc/letsencrypt /var/lib/dsh-mobile-gateway /var/lib/letsencrypt /var/log/letsencrypt /run/dsh-mobile-gateway
|
|
116
|
+
|
|
117
|
+
[Install]
|
|
118
|
+
WantedBy=multi-user.target
|
|
119
|
+
`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function invokingUserId() {
|
|
123
|
+
const value = Number(process.env.SUDO_UID)
|
|
124
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
125
|
+
throw new Error('setup-helper must be run with sudo from the user that runs dsh web')
|
|
126
|
+
}
|
|
127
|
+
return value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function init() {
|
|
131
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
132
|
+
throw new Error('init must run as the normal DSH user, without sudo')
|
|
133
|
+
}
|
|
134
|
+
if (!commandExists('dsh') || !commandExists('sudo')) {
|
|
135
|
+
throw new Error('init requires dsh and sudo on PATH')
|
|
136
|
+
}
|
|
137
|
+
run('dsh', ['plugin', '--profile', 'web', 'add', 'dsh-plugin-mobile-gateway@latest'])
|
|
138
|
+
const script = fileURLToPath(import.meta.url)
|
|
139
|
+
run('sudo', ['env', `PATH=${process.env.PATH || ''}`, process.execPath, script, 'setup-helper'])
|
|
140
|
+
console.log('\nInitialization completed. Start or restart DSH with: dsh web')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setupHelper() {
|
|
144
|
+
assertRoot()
|
|
145
|
+
const uid = invokingUserId()
|
|
146
|
+
if (!commandExists('apt-get') || !commandExists('systemctl')) {
|
|
147
|
+
throw new Error('helper installation currently supports systemd-based Ubuntu/Debian servers only')
|
|
148
|
+
}
|
|
149
|
+
run('apt-get', ['update'])
|
|
150
|
+
run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
|
|
151
|
+
if (!fs.existsSync(CERTBOT)) {
|
|
152
|
+
run('python3', ['-m', 'venv', CERTBOT_HOME])
|
|
153
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
|
|
154
|
+
}
|
|
155
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
|
|
156
|
+
for (const directory of [CONFIG_DIR, WEBROOT, '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt']) {
|
|
157
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o755 })
|
|
158
|
+
}
|
|
159
|
+
writeInstalledHelper(HELPER_SOURCE, HELPER_INSTALL)
|
|
160
|
+
writeManagedFile(HELPER_SERVICE, helperService(uid))
|
|
161
|
+
writeManagedFile(RENEW_SERVICE, renewalService())
|
|
162
|
+
writeManagedFile(RENEW_TIMER, renewalTimer())
|
|
163
|
+
run('systemctl', ['daemon-reload'])
|
|
164
|
+
run('systemctl', ['enable', '--now', path.basename(HELPER_SERVICE)])
|
|
165
|
+
run('systemctl', ['restart', path.basename(HELPER_SERVICE)])
|
|
166
|
+
run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
|
|
167
|
+
console.log(`\nHelper installed for uid ${uid}. Public access can now be configured from the Mobile Devices panel.`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function removeHelper(options) {
|
|
171
|
+
assertRoot()
|
|
172
|
+
if (!await confirm('Remove the privileged mobile gateway helper? Existing Nginx configuration will be kept.', options.yes)) {
|
|
173
|
+
console.log('Cancelled.')
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (commandExists('systemctl')) {
|
|
177
|
+
try { run('systemctl', ['disable', '--now', path.basename(HELPER_SERVICE)]) } catch {}
|
|
178
|
+
}
|
|
179
|
+
for (const file of [HELPER_SERVICE, HELPER_INSTALL]) {
|
|
180
|
+
if (!fs.existsSync(file)) continue
|
|
181
|
+
const existing = fs.readFileSync(file, 'utf8')
|
|
182
|
+
if (!existing.slice(0, 256).includes(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
|
|
183
|
+
fs.rmSync(file)
|
|
184
|
+
console.log(`Removed ${file}`)
|
|
185
|
+
}
|
|
186
|
+
if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
|
|
187
|
+
}
|
|
188
|
+
|
|
77
189
|
function metadataPublicIp() {
|
|
78
190
|
return new Promise((resolve) => {
|
|
79
191
|
const request = http.get({
|
|
@@ -310,6 +422,7 @@ export {
|
|
|
310
422
|
nginxHttpConfig,
|
|
311
423
|
nginxTlsConfig,
|
|
312
424
|
parseArgs,
|
|
425
|
+
helperService,
|
|
313
426
|
renewalService,
|
|
314
427
|
renewalTimer,
|
|
315
428
|
}
|
|
@@ -329,6 +442,9 @@ if (isMainModule(process.argv[1])) {
|
|
|
329
442
|
try {
|
|
330
443
|
const options = parseArgs(process.argv.slice(2))
|
|
331
444
|
if (options.command === 'help') printHelp()
|
|
445
|
+
else if (options.command === 'init') await init()
|
|
446
|
+
else if (options.command === 'setup-helper') setupHelper()
|
|
447
|
+
else if (options.command === 'remove-helper') await removeHelper(options)
|
|
332
448
|
else if (options.command === 'setup') await setup(options)
|
|
333
449
|
else if (options.command === 'status') status()
|
|
334
450
|
else if (options.command === 'remove') await remove(options)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Managed by dsh-plugin-mobile-gateway
|
|
3
|
+
"""Root-only, deliberately narrow system helper for the mobile gateway."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import socketserver
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
VERSION = 1
|
|
18
|
+
MARKER = "# Managed by dsh-plugin-mobile-gateway"
|
|
19
|
+
SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
|
|
20
|
+
CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
|
|
21
|
+
PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
|
|
22
|
+
NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
|
|
23
|
+
WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
|
|
24
|
+
CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
|
|
25
|
+
CERTBOT = CERTBOT_HOME / "bin/certbot"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(args):
|
|
29
|
+
subprocess.run(args, check=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_managed_write(target, content, mode=0o644):
|
|
33
|
+
target = pathlib.Path(target)
|
|
34
|
+
if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
|
|
35
|
+
raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
|
|
36
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
|
|
38
|
+
temporary.write_text(content, encoding="utf-8")
|
|
39
|
+
os.chmod(temporary, mode)
|
|
40
|
+
os.replace(temporary, target)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_public_ip(value):
|
|
44
|
+
try:
|
|
45
|
+
address = ipaddress.ip_address(value)
|
|
46
|
+
except ValueError as error:
|
|
47
|
+
raise ValueError("publicIp must be a valid IPv4 address") from error
|
|
48
|
+
if address.version != 4 or not address.is_global:
|
|
49
|
+
raise ValueError("publicIp must be a public IPv4 address")
|
|
50
|
+
return str(address)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_port(value):
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
|
|
55
|
+
raise ValueError("backendPort must be an integer between 1 and 65535")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def certificate_name(public_ip):
|
|
60
|
+
return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def nginx_http_config(public_ip):
|
|
64
|
+
return f"""{MARKER}
|
|
65
|
+
server {{
|
|
66
|
+
listen 80;
|
|
67
|
+
server_name {public_ip};
|
|
68
|
+
|
|
69
|
+
location ^~ /.well-known/acme-challenge/ {{
|
|
70
|
+
root {WEBROOT};
|
|
71
|
+
default_type text/plain;
|
|
72
|
+
}}
|
|
73
|
+
|
|
74
|
+
location / {{ return 404; }}
|
|
75
|
+
}}
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def nginx_tls_config(public_ip, backend_port):
|
|
80
|
+
live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
|
|
81
|
+
return nginx_http_config(public_ip) + f"""
|
|
82
|
+
server {{
|
|
83
|
+
listen 443 ssl;
|
|
84
|
+
server_name {public_ip};
|
|
85
|
+
server_tokens off;
|
|
86
|
+
access_log off;
|
|
87
|
+
|
|
88
|
+
ssl_certificate {live}/fullchain.pem;
|
|
89
|
+
ssl_certificate_key {live}/privkey.pem;
|
|
90
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
91
|
+
|
|
92
|
+
location = /ws/mobile {{
|
|
93
|
+
if ($args != \"\") {{ return 404; }}
|
|
94
|
+
proxy_pass http://127.0.0.1:{backend_port};
|
|
95
|
+
proxy_http_version 1.1;
|
|
96
|
+
proxy_set_header Host 127.0.0.1:{backend_port};
|
|
97
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
98
|
+
proxy_set_header Connection \"upgrade\";
|
|
99
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
100
|
+
proxy_read_timeout 3600s;
|
|
101
|
+
proxy_send_timeout 3600s;
|
|
102
|
+
proxy_buffering off;
|
|
103
|
+
}}
|
|
104
|
+
|
|
105
|
+
location / {{ return 404; }}
|
|
106
|
+
}}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def current_status():
|
|
111
|
+
public_url = None
|
|
112
|
+
if PUBLIC_URL_FILE.exists():
|
|
113
|
+
public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
|
|
114
|
+
if line.strip().startswith("wss://")), None)
|
|
115
|
+
backend_port = None
|
|
116
|
+
if NGINX_CONFIG.exists():
|
|
117
|
+
match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
|
|
118
|
+
if match:
|
|
119
|
+
backend_port = int(match.group(1))
|
|
120
|
+
return {
|
|
121
|
+
"installed": True,
|
|
122
|
+
"version": VERSION,
|
|
123
|
+
"configured": bool(public_url and backend_port),
|
|
124
|
+
"publicUrl": public_url,
|
|
125
|
+
"backendPort": backend_port,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_dependencies():
|
|
130
|
+
if not shutil.which("nginx") or not CERTBOT.exists():
|
|
131
|
+
raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def configure(public_ip, backend_port):
|
|
135
|
+
public_ip = validate_public_ip(public_ip)
|
|
136
|
+
backend_port = validate_port(backend_port)
|
|
137
|
+
previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
|
|
138
|
+
if previous is not None and not previous.decode("utf-8").startswith(MARKER):
|
|
139
|
+
raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
|
|
140
|
+
|
|
141
|
+
ensure_dependencies()
|
|
142
|
+
(WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
|
|
143
|
+
try:
|
|
144
|
+
atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
|
|
145
|
+
run(["nginx", "-t"])
|
|
146
|
+
run(["systemctl", "enable", "--now", "nginx"])
|
|
147
|
+
run(["systemctl", "reload", "nginx"])
|
|
148
|
+
|
|
149
|
+
args = [
|
|
150
|
+
str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
|
|
151
|
+
"--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
|
|
152
|
+
"--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
|
|
153
|
+
"--keep-until-expiring", "--register-unsafely-without-email",
|
|
154
|
+
]
|
|
155
|
+
run(args)
|
|
156
|
+
atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
|
|
157
|
+
atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
|
|
158
|
+
run(["nginx", "-t"])
|
|
159
|
+
run(["systemctl", "reload", "nginx"])
|
|
160
|
+
except Exception:
|
|
161
|
+
if previous is None:
|
|
162
|
+
NGINX_CONFIG.unlink(missing_ok=True)
|
|
163
|
+
else:
|
|
164
|
+
NGINX_CONFIG.write_bytes(previous)
|
|
165
|
+
if shutil.which("nginx"):
|
|
166
|
+
subprocess.run(["nginx", "-t"], check=False)
|
|
167
|
+
subprocess.run(["systemctl", "reload", "nginx"], check=False)
|
|
168
|
+
raise
|
|
169
|
+
return current_status()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def dispatch(message):
|
|
173
|
+
action = message.get("action")
|
|
174
|
+
if action == "status":
|
|
175
|
+
return current_status()
|
|
176
|
+
if action == "configure":
|
|
177
|
+
return configure(message.get("publicIp"), message.get("backendPort"))
|
|
178
|
+
raise ValueError("unsupported helper action")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
182
|
+
def handle(self):
|
|
183
|
+
try:
|
|
184
|
+
raw = self.rfile.readline(65537)
|
|
185
|
+
if not raw or len(raw) > 65536:
|
|
186
|
+
raise ValueError("invalid helper request")
|
|
187
|
+
message = json.loads(raw.decode("utf-8"))
|
|
188
|
+
if not isinstance(message, dict):
|
|
189
|
+
raise ValueError("helper request must be an object")
|
|
190
|
+
response = {"ok": True, "result": dispatch(message)}
|
|
191
|
+
except Exception as error: # Return a typed error; never expose a traceback.
|
|
192
|
+
response = {"ok": False, "error": str(error)}
|
|
193
|
+
self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Server(socketserver.UnixStreamServer):
|
|
197
|
+
allow_reuse_address = True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def serve(uid, socket_path):
|
|
201
|
+
if os.geteuid() != 0:
|
|
202
|
+
raise RuntimeError("helper must run as root")
|
|
203
|
+
path = pathlib.Path(socket_path)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path.unlink(missing_ok=True)
|
|
206
|
+
with Server(str(path), Handler) as server:
|
|
207
|
+
os.chown(path, uid, -1)
|
|
208
|
+
os.chmod(path, 0o600)
|
|
209
|
+
server.serve_forever()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main():
|
|
213
|
+
parser = argparse.ArgumentParser()
|
|
214
|
+
parser.add_argument("--uid", type=int, required=True)
|
|
215
|
+
parser.add_argument("--socket", default=SOCKET_PATH)
|
|
216
|
+
options = parser.parse_args()
|
|
217
|
+
if options.uid < 1:
|
|
218
|
+
raise ValueError("uid must identify a non-root user")
|
|
219
|
+
serve(options.uid, options.socket)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
try:
|
|
224
|
+
main()
|
|
225
|
+
except Exception as error:
|
|
226
|
+
print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
|
|
227
|
+
raise SystemExit(1)
|
package/lib/client.js
CHANGED
|
@@ -116,6 +116,9 @@ window.__ModuleLoader__.load({
|
|
|
116
116
|
const [devices, setDevices] = React.useState(null)
|
|
117
117
|
const [status, setStatus] = React.useState(null)
|
|
118
118
|
const [publicUrl, setPublicUrl] = React.useState('')
|
|
119
|
+
const [publicIp, setPublicIp] = React.useState('')
|
|
120
|
+
const [publicSetup, setPublicSetup] = React.useState(null)
|
|
121
|
+
const [publicSetupBusy, setPublicSetupBusy] = React.useState(false)
|
|
119
122
|
const [deviceName, setDeviceName] = React.useState('iPhone')
|
|
120
123
|
const [qr, setQr] = React.useState(null)
|
|
121
124
|
const [error, setError] = React.useState(null)
|
|
@@ -175,10 +178,40 @@ window.__ModuleLoader__.load({
|
|
|
175
178
|
React.useEffect(() => {
|
|
176
179
|
let active = true
|
|
177
180
|
refresh()
|
|
181
|
+
request('/mgw/public-setup').then((data) => {
|
|
182
|
+
if (!active) return
|
|
183
|
+
setPublicSetup(data)
|
|
184
|
+
if (data.publicUrl) {
|
|
185
|
+
const match = /^wss:\/\/([^/]+)\/ws\/mobile$/.exec(data.publicUrl)
|
|
186
|
+
if (match) setPublicIp(match[1])
|
|
187
|
+
}
|
|
188
|
+
}).catch((cause) => active && setPublicSetup({ installed: false, configured: false, error: cause.message }))
|
|
178
189
|
const timer = window.setInterval(refresh, 3000)
|
|
179
190
|
return () => { active = false; window.clearInterval(timer) }
|
|
180
191
|
}, [refresh])
|
|
181
192
|
|
|
193
|
+
const configurePublicAccess = async () => {
|
|
194
|
+
if (publicSetupBusy) return
|
|
195
|
+
setPublicSetupBusy(true)
|
|
196
|
+
setError(null)
|
|
197
|
+
try {
|
|
198
|
+
const data = await request('/mgw/public-setup', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ publicIp }),
|
|
202
|
+
})
|
|
203
|
+
setPublicSetup(data)
|
|
204
|
+
if (data.publicUrl) {
|
|
205
|
+
automaticUrlRef.current = data.publicUrl
|
|
206
|
+
setPublicUrl(data.publicUrl)
|
|
207
|
+
}
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
setError(cause.message)
|
|
210
|
+
} finally {
|
|
211
|
+
setPublicSetupBusy(false)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
const createPairing = async (endpoint) => request('/mgw/pair', {
|
|
183
216
|
method: 'POST',
|
|
184
217
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -331,6 +364,37 @@ window.__ModuleLoader__.load({
|
|
|
331
364
|
status && !status.requireAuth
|
|
332
365
|
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
|
|
333
366
|
: null),
|
|
367
|
+
React.createElement('section', { style: styles.card },
|
|
368
|
+
React.createElement('strong', null, '公网接入'),
|
|
369
|
+
publicSetup === null
|
|
370
|
+
? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
|
|
371
|
+
: publicSetup.installed
|
|
372
|
+
? React.createElement(React.Fragment, null,
|
|
373
|
+
React.createElement('div', { style: { ...styles.muted, margin: '5px 0 12px' } }, publicSetup.configured
|
|
374
|
+
? publicSetup.backendPort && status && status.webPort && publicSetup.backendPort !== status.webPort
|
|
375
|
+
? `端口需要更新:Nginx 当前为 ${publicSetup.backendPort},DSH 当前为 ${status.webPort}`
|
|
376
|
+
: `已配置${publicSetup.backendPort ? `,Nginx 转发至 DSH 端口 ${publicSetup.backendPort}` : ''}`
|
|
377
|
+
: `Helper 已就绪,将自动使用当前 DSH 端口${status && status.webPort ? ` ${status.webPort}` : ''}`),
|
|
378
|
+
React.createElement('label', { style: styles.label }, '服务器公网 IPv4'),
|
|
379
|
+
React.createElement('input', {
|
|
380
|
+
style: styles.input,
|
|
381
|
+
value: publicIp,
|
|
382
|
+
onChange: (event) => setPublicIp(event.target.value.trim()),
|
|
383
|
+
placeholder: '203.0.113.10',
|
|
384
|
+
inputMode: 'decimal',
|
|
385
|
+
spellCheck: false,
|
|
386
|
+
}),
|
|
387
|
+
React.createElement('button', {
|
|
388
|
+
style: { ...styles.button, ...styles.primary, opacity: publicSetupBusy || !publicIp ? .65 : 1 },
|
|
389
|
+
disabled: publicSetupBusy || !publicIp,
|
|
390
|
+
onClick: configurePublicAccess,
|
|
391
|
+
}, publicSetupBusy ? '正在配置 Nginx 与证书…' : publicSetup.configured ? '更新公网配置' : '配置公网接入'),
|
|
392
|
+
publicSetup.publicUrl
|
|
393
|
+
? React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere' } }, publicSetup.publicUrl)
|
|
394
|
+
: null)
|
|
395
|
+
: React.createElement(React.Fragment, null,
|
|
396
|
+
React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
|
|
397
|
+
React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))),
|
|
334
398
|
React.createElement('section', { style: styles.card },
|
|
335
399
|
React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
|
|
336
400
|
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { automaticUrlRef.current = ''; setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
|
package/lib/index.mjs
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// { "type": "default-model" } -> agentDefaultModel.currentSelection()
|
|
39
39
|
// { "type": "save-default-model", "provider", "model", "reasoningEffort"? }
|
|
40
40
|
// { "type": "directories", "path"? } -> server dir listing (fs-based)
|
|
41
|
+
// { "type": "directory-create", "path", "name" } -> create one child directory
|
|
41
42
|
// { "type": "workspace-create", "path" } -> create workspace over a dir
|
|
42
43
|
// { "type": "fork", "sessionId", "atSeq"? } -> branch a new session from a completed turn
|
|
43
44
|
// { "type": "models", "sessionId"? } -> per-session catalog (with sessionId) or global (without)
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
import fs from 'node:fs'
|
|
69
70
|
import fsp from 'node:fs/promises'
|
|
70
71
|
import http from 'node:http'
|
|
72
|
+
import net from 'node:net'
|
|
71
73
|
import os from 'node:os'
|
|
72
74
|
import path from 'node:path'
|
|
73
75
|
import crypto from 'node:crypto'
|
|
@@ -87,6 +89,7 @@ const PLUGIN_VERSION = (() => {
|
|
|
87
89
|
})()
|
|
88
90
|
const MAX_PREVIEW = 400
|
|
89
91
|
const LOG_FILE = '/tmp/mobile-gateway.log'
|
|
92
|
+
const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
90
93
|
const DEFAULT_WS_PATH = '/ws/mobile'
|
|
91
94
|
const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
|
|
92
95
|
const DEFAULT_GATEWAY_WAIT_TIMEOUT_MS = 5 * 60 * 1000
|
|
@@ -380,6 +383,56 @@ async function listServerDirectory(target) {
|
|
|
380
383
|
}
|
|
381
384
|
}
|
|
382
385
|
|
|
386
|
+
function directoryCreateError(code, message) {
|
|
387
|
+
return { kind: 'error', code, message, requestType: 'directory-create' }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Create one child directory directly on the host filesystem. This deliberately
|
|
391
|
+
// shares the same backend as `directories`: macOS native Directory Picker only
|
|
392
|
+
// exposes its system dialog and does not implement remote createDirectory RPCs.
|
|
393
|
+
async function createServerDirectory(parentPath, directoryName) {
|
|
394
|
+
if (typeof parentPath !== 'string' || parentPath.trim() === '') {
|
|
395
|
+
return directoryCreateError('bad-request', 'directory-create requires an absolute parent path')
|
|
396
|
+
}
|
|
397
|
+
const parent = path.resolve(parentPath.trim())
|
|
398
|
+
if (!path.isAbsolute(parentPath.trim())) {
|
|
399
|
+
return directoryCreateError('bad-request', 'directory-create path must be absolute')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (typeof directoryName !== 'string') {
|
|
403
|
+
return directoryCreateError('bad-request', 'directory-create requires a folder name')
|
|
404
|
+
}
|
|
405
|
+
const name = directoryName.trim()
|
|
406
|
+
if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
|
407
|
+
return directoryCreateError('bad-request', 'directory-create name must be a single non-empty folder name')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const stat = await fsp.stat(parent)
|
|
412
|
+
if (!stat.isDirectory()) {
|
|
413
|
+
return directoryCreateError('directory-create-failed', 'parent path is not a directory')
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const message = error && error.message ? error.message : String(error)
|
|
417
|
+
log(`query failed: directory-create -> ${message}`)
|
|
418
|
+
return directoryCreateError('directory-create-failed', message)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const target = path.join(parent, name)
|
|
422
|
+
try {
|
|
423
|
+
await fsp.mkdir(target)
|
|
424
|
+
log(`query ok: directory-create (${target})`)
|
|
425
|
+
return { kind: 'directory-create', path: target }
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error && error.code === 'EEXIST') {
|
|
428
|
+
return directoryCreateError('directory-exists', 'directory already exists')
|
|
429
|
+
}
|
|
430
|
+
const message = error && error.message ? error.message : String(error)
|
|
431
|
+
log(`query failed: directory-create -> ${message}`)
|
|
432
|
+
return directoryCreateError('directory-create-failed', message)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
383
436
|
// Validate one sessionId field; returns { value } or { error }.
|
|
384
437
|
function requireSessionId(msg) {
|
|
385
438
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
@@ -578,6 +631,9 @@ async function handleQuery(api, typertGateway, agentDefaultModel, msg) {
|
|
|
578
631
|
if (msg.type === 'directories') {
|
|
579
632
|
return listServerDirectory(typeof msg.path === 'string' && msg.path.trim() !== '' ? msg.path.trim() : undefined)
|
|
580
633
|
}
|
|
634
|
+
if (msg.type === 'directory-create') {
|
|
635
|
+
return createServerDirectory(msg.path, msg.name)
|
|
636
|
+
}
|
|
581
637
|
if (msg.type === 'models') {
|
|
582
638
|
const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
583
639
|
if (sessionId) {
|
|
@@ -888,6 +944,54 @@ function sendJson(res, status, value) {
|
|
|
888
944
|
res.end(body)
|
|
889
945
|
}
|
|
890
946
|
|
|
947
|
+
function callSystemHelper(message, timeoutMs = 2_000) {
|
|
948
|
+
return new Promise((resolve, reject) => {
|
|
949
|
+
let settled = false
|
|
950
|
+
let body = ''
|
|
951
|
+
const finish = (error, value) => {
|
|
952
|
+
if (settled) return
|
|
953
|
+
settled = true
|
|
954
|
+
socket.destroy()
|
|
955
|
+
if (error) reject(error)
|
|
956
|
+
else resolve(value)
|
|
957
|
+
}
|
|
958
|
+
const socket = net.createConnection(HELPER_SOCKET)
|
|
959
|
+
socket.setEncoding('utf8')
|
|
960
|
+
socket.setTimeout(timeoutMs)
|
|
961
|
+
socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`))
|
|
962
|
+
socket.on('data', (chunk) => {
|
|
963
|
+
body += chunk
|
|
964
|
+
if (body.length > 1024 * 1024) return finish(new Error('system helper response is too large'))
|
|
965
|
+
const newline = body.indexOf('\n')
|
|
966
|
+
if (newline < 0) return
|
|
967
|
+
try {
|
|
968
|
+
const response = JSON.parse(body.slice(0, newline))
|
|
969
|
+
if (!response || response.ok !== true) throw new Error(response && response.error ? response.error : 'system helper request failed')
|
|
970
|
+
finish(null, response.result)
|
|
971
|
+
} catch (error) {
|
|
972
|
+
finish(error)
|
|
973
|
+
}
|
|
974
|
+
})
|
|
975
|
+
socket.on('timeout', () => finish(new Error('system helper request timed out')))
|
|
976
|
+
socket.on('error', (error) => finish(error))
|
|
977
|
+
socket.on('end', () => {
|
|
978
|
+
if (!settled) finish(new Error('system helper closed without a response'))
|
|
979
|
+
})
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function systemHelperStatus() {
|
|
984
|
+
try {
|
|
985
|
+
return await callSystemHelper({ action: 'status' })
|
|
986
|
+
} catch (error) {
|
|
987
|
+
return {
|
|
988
|
+
installed: false,
|
|
989
|
+
configured: false,
|
|
990
|
+
error: error && error.message ? error.message : String(error),
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
891
995
|
const plugin = {
|
|
892
996
|
name: 'mobile-gateway',
|
|
893
997
|
Config,
|
|
@@ -1101,6 +1205,7 @@ const plugin = {
|
|
|
1101
1205
|
gatewayEnabled,
|
|
1102
1206
|
waitExpiresAt,
|
|
1103
1207
|
connectedClients: clients.size,
|
|
1208
|
+
webPort: webServer.port,
|
|
1104
1209
|
wsPath,
|
|
1105
1210
|
publicUrl: configuredPublicUrl() || null,
|
|
1106
1211
|
lan: {
|
|
@@ -1115,6 +1220,18 @@ const plugin = {
|
|
|
1115
1220
|
pairingTtlMs: options.pairingTtlMs,
|
|
1116
1221
|
queryTokenAllowed: !!options.allowQueryToken,
|
|
1117
1222
|
})
|
|
1223
|
+
} else if (req.method === 'GET' && p === '/mgw/public-setup') {
|
|
1224
|
+
sendJson(res, 200, await systemHelperStatus())
|
|
1225
|
+
} else if (req.method === 'POST' && p === '/mgw/public-setup') {
|
|
1226
|
+
const body = await readBody(req)
|
|
1227
|
+
if (typeof body.publicIp !== 'string' || !body.publicIp.trim()) throw badRequest('publicIp is required')
|
|
1228
|
+
const result = await callSystemHelper({
|
|
1229
|
+
action: 'configure',
|
|
1230
|
+
publicIp: body.publicIp.trim(),
|
|
1231
|
+
backendPort: webServer.port,
|
|
1232
|
+
}, 15 * 60 * 1000)
|
|
1233
|
+
log(`public endpoint configured: ${result.publicUrl} -> 127.0.0.1:${result.backendPort}`)
|
|
1234
|
+
sendJson(res, 200, result)
|
|
1118
1235
|
} else if (req.method === 'POST' && p === '/mgw/gateway') {
|
|
1119
1236
|
const body = await readBody(req)
|
|
1120
1237
|
if (typeof body.enabled !== 'boolean') throw badRequest('enabled must be a boolean')
|
|
@@ -1269,7 +1386,7 @@ const plugin = {
|
|
|
1269
1386
|
if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
|
|
1270
1387
|
})
|
|
1271
1388
|
} else if (msg.type === 'workspaces' || msg.type === 'sessions' || msg.type === 'history' || msg.type === 'attachment' ||
|
|
1272
|
-
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' ||
|
|
1389
|
+
msg.type === 'search' || msg.type === 'host' || msg.type === 'directories' || msg.type === 'directory-create' ||
|
|
1273
1390
|
msg.type === 'workspace-create' || msg.type === 'models' || msg.type === 'select-model' ||
|
|
1274
1391
|
msg.type === 'permission-options' || msg.type === 'permission' || msg.type === 'context-usage' ||
|
|
1275
1392
|
msg.type === 'agent-presets' || msg.type === 'defaults' || msg.type === 'set-default' ||
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.3",
|
|
4
|
+
"description": "update readme",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
8
|
+
"helper",
|
|
8
9
|
"lib",
|
|
9
10
|
"docs",
|
|
10
11
|
"cordis.patch.yml",
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
# 公网 IP 命令行配对指南
|
|
2
|
-
|
|
3
|
-
适用于通过 nvm 安装 Node.js、已经可以直接使用 `dsh` 命令的 Ubuntu/Debian 服务器。服务器端不需要打开 WebUI:终端会直接输出 iOS 所需的 Base64URL 配对字符串。
|
|
4
|
-
|
|
5
|
-
## 1. 放行公网端口
|
|
6
|
-
|
|
7
|
-
先在云安全组中放行 TCP `80` 和 `443`。不要开放 `3080`、`3081` 或 `/mgw/*`。
|
|
8
|
-
|
|
9
|
-
如果服务器启用了 UFW:
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
sudo ufw allow 80/tcp
|
|
13
|
-
sudo ufw allow 443/tcp
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## 2. 安装插件和辅助命令
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
dsh plugin --profile web add dsh-plugin-mobile-gateway@latest
|
|
20
|
-
sudo apt-get update
|
|
21
|
-
sudo apt-get install -y jq tmux
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## 3. 配置公网 WSS
|
|
25
|
-
|
|
26
|
-
获取服务器公网 IPv4:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
PUBLIC_IP="$(curl -4 -fsS https://ifconfig.me)"
|
|
30
|
-
printf 'Public IP: %s\n' "$PUBLIC_IP"
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
如果显示的不是服务器实际公网 IPv4,请手动执行 `PUBLIC_IP="实际公网IP"`。
|
|
34
|
-
|
|
35
|
-
因为 Node.js 来自 nvm,运行安装器时需要把当前 `PATH` 传给 `sudo`:
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest setup \
|
|
39
|
-
--ip "$PUBLIC_IP" \
|
|
40
|
-
--port 3080
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
安装成功后检查:
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
sudo env "PATH=$PATH" npx --yes dsh-plugin-mobile-gateway@latest status
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
应显示:
|
|
50
|
-
|
|
51
|
-
```text
|
|
52
|
-
wss://<公网IP>/ws/mobile
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
## 4. 启动 DSH
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
tmux new -s dsh
|
|
59
|
-
dsh web
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
看到 `dsh web: http://127.0.0.1:3080` 后,按 `Ctrl+B`,再按 `D`,让 DSH 在后台继续运行。
|
|
63
|
-
|
|
64
|
-
## 5. 在终端生成配对字符串
|
|
65
|
-
|
|
66
|
-
重新读取公网地址,并明确开启设备鉴权和移动网关:
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
PUBLIC_IP="$(curl -4 -fsS https://ifconfig.me)"
|
|
70
|
-
PUBLIC_URL="wss://$PUBLIC_IP/ws/mobile"
|
|
71
|
-
|
|
72
|
-
curl -fsS -X POST http://127.0.0.1:3080/mgw/auth \
|
|
73
|
-
-H 'Content-Type: application/json' \
|
|
74
|
-
-d '{"enabled":true}'
|
|
75
|
-
|
|
76
|
-
curl -fsS -X POST http://127.0.0.1:3080/mgw/gateway \
|
|
77
|
-
-H 'Content-Type: application/json' \
|
|
78
|
-
-d '{"enabled":true}'
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
生成一次性 Base64URL 配对字符串:
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
PAIRING_TEXT="$(
|
|
85
|
-
jq -nc --arg name 'iPhone' --arg url "$PUBLIC_URL" \
|
|
86
|
-
'{name:$name,publicUrl:$url}' \
|
|
87
|
-
| curl -fsS -X POST http://127.0.0.1:3080/mgw/pair \
|
|
88
|
-
-H 'Content-Type: application/json' \
|
|
89
|
-
--data-binary @- \
|
|
90
|
-
| jq -r '.qrPayload'
|
|
91
|
-
)"
|
|
92
|
-
|
|
93
|
-
if [ -z "$PAIRING_TEXT" ] || [ "$PAIRING_TEXT" = 'null' ]; then
|
|
94
|
-
echo '生成配对字符串失败,请检查 DSH 和插件日志'
|
|
95
|
-
else
|
|
96
|
-
printf '\n复制下面这一整行到 iPhone:\n\n%s\n\n' "$PAIRING_TEXT"
|
|
97
|
-
fi
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
配对字符串只能使用一次,并在 5 分钟后过期。如果超时,重新执行本节最后两段命令。
|
|
101
|
-
|
|
102
|
-
## 6. 在 iPhone 完成绑定
|
|
103
|
-
|
|
104
|
-
1. 打开 iOS 客户端的“设备认证”。
|
|
105
|
-
2. 把终端输出的整行内容粘贴到“手动输入配对信息”。
|
|
106
|
-
3. 点击“配对并连接”或“重新配对并连接”。
|
|
107
|
-
4. 状态显示“已连接”后点击“完成”。
|
|
108
|
-
|
|
109
|
-
iOS 会把长期设备凭证保存到 Keychain,之后启动时会自动重新连接,不需要再次执行配对。
|
|
110
|
-
|
|
111
|
-
## 7. 检查结果
|
|
112
|
-
|
|
113
|
-
服务器查看可信设备:
|
|
114
|
-
|
|
115
|
-
```bash
|
|
116
|
-
curl -fsS http://127.0.0.1:3080/mgw/devices | jq
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
查看插件日志:
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
tail -f /tmp/mobile-gateway.log
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
常见结果:
|
|
126
|
-
|
|
127
|
-
- `503`:移动网关未开启,重新执行第 5 节的网关开启命令。
|
|
128
|
-
- `401`:配对字符串过期、已使用或客户端凭证无效,重新生成配对字符串。
|
|
129
|
-
- 公网连接超时:检查云安全组、UFW、Nginx,以及 TCP `80/443`。
|
|
130
|
-
|
|
131
|
-
查看后台 DSH:
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
tmux attach -t dsh
|
|
135
|
-
```
|