dsh-plugin-mobile-gateway 0.4.2 → 0.6.0

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,542 @@
1
+ # 让手机成为 AI Agent 的第二块屏幕:dsh-plugin-mobile-gateway
2
+
3
+ > 一个给 DeepSeek Harness 写的常驻插件:在不改动宿主一行代码的前提下,为移动端加上一条经过设备鉴权的实时通道。
4
+
5
+ ---
6
+
7
+ ## 一、它解决什么问题
8
+
9
+ 用过 CLI 形态 Coding Agent 的人大概都有同一个体验:给 Agent 派一个稍复杂的任务,它开始读文件、跑测试、改代码,一轮下来三五分钟。这三五分钟里你被钉在电脑前——不是因为你要做什么,而是因为你**不知道它做到哪了**。
10
+
11
+ `dsh-plugin-mobile-gateway` 要解决的就是这件事:
12
+
13
+ > **把手机变成远端 Agent 的第二块屏幕**——实时看到思考、工具调用和回答,随时下发新任务。
14
+
15
+ ![移动设备管理面板](assets/mobile-device-management.png)
16
+
17
+ 它的定位很轻:**不重新实现任何 Agent 逻辑**,只是把 DSH 浏览器 UI 用的那套官方 Host API 和 `session/event` 事件流,转译成一套精简的 JSON WebSocket 协议转发给手机。
18
+
19
+ ---
20
+
21
+ ## 二、当前支持的网络范围
22
+
23
+ 这是需要先说清楚的边界:
24
+
25
+ | 场景 | 支持情况 |
26
+ |---|---|
27
+ | 家庭局域网(同一路由器下) | ✅ 已支持,零配置 |
28
+ | 办公局域网 | ✅ 已支持,零配置 |
29
+ | 可信 VPN 网络(WireGuard / 企业 VPN 等) | ✅ 已支持 |
30
+ | 本机浏览器调试 | ✅ 已支持 |
31
+ | **公网直连** | 🚧 **开发中** |
32
+
33
+ 也就是说,**当前版本的定位是"可信私有网络内的移动端遥控"**。手机和电脑需要处在同一个可互访的私有网络里——同一个 Wi-Fi,或者通过 VPN 接入同一段内网。
34
+
35
+ > VPN 场景的前提:VPN 需要给客户端分配 RFC 1918 私有地址(如 `10.x.x.x`、`192.168.x.x`、`172.16-31.x.x`),这样才能通过插件的来源校验。
36
+
37
+ 公网连接能力正在开发中。这不是技术难度问题,而是**安全边界问题**:一旦端点暴露到公网,就必须同时解决 TLS 终止、证书生命周期、速率限制、日志脱敏、暴力破解防护等一整套问题。在这些没有稳妥收口之前,插件选择把网络面**严格限制在私有网段**,宁可少一个场景,不留一个开口。
38
+
39
+ ---
40
+
41
+ ## 三、连接原理
42
+
43
+ ### 3.1 两个监听器,各管一段
44
+
45
+ DSH 自己坚持只监听 `127.0.0.1`,这是它的安全立场。插件没有去改宿主、也没有劝它放开,而是**自己开了第二个窄口径 HTTP server**:
46
+
47
+ | 监听器 | 地址 | 提供什么 |
48
+ |---|---|---|
49
+ | DSH 自带(宿主) | `127.0.0.1:3080` | WebUI + `/mgw` 管理接口 + `/ws/mobile` |
50
+ | **插件自建(LAN)** | `0.0.0.0:3081` | **只有** `/ws/mobile`,别的全部 404 |
51
+
52
+ 插件自建的这个监听器做了三层收窄:
53
+
54
+ ```js
55
+ lanServer = http.createServer((req, res) => {
56
+ sendJson(res, 404, { error: 'not-found' }) // ① 普通 HTTP 请求一律 404
57
+ })
58
+
59
+ lanServer.on('upgrade', (req, socket, head) => {
60
+ if (pathname !== wsPath) {
61
+ rejectUpgrade(socket, 404, 'not found') // ② 只认 /ws/mobile 这一条路径
62
+ return
63
+ }
64
+ if (!isPrivateNetworkHostname(req.socket.remoteAddress)) {
65
+ rejectUpgrade(socket, 403, // ③ 只接受私有网段来源
66
+ 'LAN listener accepts private-network clients only')
67
+ return
68
+ }
69
+ handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
70
+ })
71
+ ```
72
+
73
+ 它**不提供 WebUI、不提供管理接口**。暴露到局域网的,只是一个"只能做鉴权 WebSocket 连接"的端点。
74
+
75
+ 更关键的一点:WebUI 上那个 Debug 用的"关闭设备鉴权"开关**管不到它**——
76
+
77
+ ```js
78
+ // LAN 入口无条件强制鉴权,Debug 开关只影响 loopback
79
+ if ((requireAuth || transport.lan === true) && !device) {
80
+ logAuthRejected(req)
81
+ rejectUpgrade(socket, 401, 'missing or invalid device credential')
82
+ return
83
+ }
84
+ ```
85
+
86
+ 一个"方便调试"的开关,永远不可能把暴露在局域网上的端点变成开放控制面。
87
+
88
+ ### 3.2 私有网段判定
89
+
90
+ 来源校验和地址合法性校验都走同一个函数,覆盖 IPv4 私有段、链路本地、`.local` 主机名,以及 IPv6 的 `fc00::/7` 和 `fe80::/10`:
91
+
92
+ ```js
93
+ function isPrivateNetworkHostname(hostname) {
94
+ let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
95
+ if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
96
+ if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
97
+
98
+ const octets = normalized.split('.').map(Number)
99
+ if (octets.length === 4 && octets.every((p) => Number.isInteger(p) && p >= 0 && p <= 255)) {
100
+ return octets[0] === 10 // 10.0.0.0/8
101
+ || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) // 172.16.0.0/12
102
+ || (octets[0] === 192 && octets[1] === 168) // 192.168.0.0/16
103
+ || (octets[0] === 169 && octets[1] === 254) // 链路本地
104
+ }
105
+ return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
106
+ }
107
+ ```
108
+
109
+ 这个函数同时也是"什么地址允许用明文 `ws://`"的唯一判据。只有 localhost、`.local` 和上面这些私有段可以用 `ws://`,其他地址一律要求 `wss://`:
110
+
111
+ ```js
112
+ if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname))
113
+ throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
114
+ ```
115
+
116
+ ### 3.3 地址自动探测
117
+
118
+ 用户不需要自己查 IP。面板打开时插件会枚举网卡,并且**把虚拟网卡降级为 fallback**,优先展示物理网卡地址:
119
+
120
+ ```js
121
+ function privateLanAddresses() {
122
+ const physical = []
123
+ const fallback = []
124
+ const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
125
+ for (const [name, records] of Object.entries(os.networkInterfaces())) {
126
+ for (const record of records || []) {
127
+ if (!record || record.internal || record.family !== 'IPv4'
128
+ || !isPrivateNetworkHostname(record.address)) continue
129
+ const target = virtualInterface.test(name) ? fallback : physical
130
+ if (!target.includes(record.address)) target.push(record.address)
131
+ }
132
+ }
133
+ return physical.length ? physical : fallback
134
+ }
135
+ ```
136
+
137
+ 有了这段,面板里显示的就是 `192.168.1.23` 而不是某个 Docker 桥地址。**这个细节决定了"零配置"到底成不成立。**
138
+
139
+ 面板最终填入的地址优先级是:配置的公网地址 → 局域网探测地址 → 从当前页面推断。用户手动改过之后,轮询不会再覆盖。
140
+
141
+ ### 3.4 双向数据流
142
+
143
+ **下行(Agent → 手机)**:监听宿主的 `session/event`,和浏览器 UI 消费的是同一条 feed:
144
+
145
+ ```js
146
+ const disposeEvents = ctx.on('session/event', (session, event) => {
147
+ if (clients.size === 0) return // 无客户端时零开销
148
+ const wire = buildWireEvent(session, event) // 压成小 JSON
149
+ if (!wire) return
150
+ const payload = JSON.stringify(wire)
151
+ for (const client of clients) {
152
+ if (client.filterSessionId && client.filterSessionId !== String(session.id)) continue
153
+ if (client.readyState === 1) client.send(payload)
154
+ }
155
+ })
156
+ ```
157
+
158
+ `buildWireEvent` 遵守一条纪律(源码注释原文):
159
+
160
+ > Reads only leaf fields of the live SessionEvent — never serializes live objects.
161
+
162
+ **只读叶子字段,绝不序列化活对象。** 既避免把宿主内部结构泄漏到网线上,也避免循环引用和意外的巨型 payload。
163
+
164
+ **上行(手机 → Agent)**:走官方 API,不开旁路:
165
+
166
+ ```js
167
+ const resp = await api.sessions.prompt({
168
+ rpcId: crypto.randomUUID(),
169
+ payload: { sessionId, mode, content: [{ type: 'text', text }] },
170
+ })
171
+ ```
172
+
173
+ 手机发的消息和浏览器提交的 prompt **走完全相同的路径**。`mode` 支持 `queue`(排队)和 `steer`(打断当前回合)。
174
+
175
+ ---
176
+
177
+ ## 四、配对原理
178
+
179
+ 这是整个插件的安全核心。设计目标是:**长期凭证只在网线上出现一次,服务端磁盘上永远没有明文。**
180
+
181
+ ### 4.1 完整流程
182
+
183
+ ```
184
+ [WebUI] 点击"生成配对二维码"
185
+
186
+ ├─ createPairing()
187
+ │ 生成 256-bit 一次性配对码
188
+ │ 内存 Map 只存 SHA-256(code),明文不落盘、不持久化
189
+ │ 5 分钟后过期
190
+
191
+ ├─ payload = { version:2, publicUrl, pairingCode, expiresAt }
192
+ ├─ Base64URL(无 padding)编码
193
+ └─ 渲染成 QR SVG,同时提供文本供手动粘贴
194
+
195
+ [iOS] 扫码 / 粘贴后建立连接
196
+ │ Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-pair.<code>
197
+ │ X-DSH-Device-ID: <Keychain 中的安装级 UUID>
198
+
199
+ ├─ claimPairing():先 delete 再干活 → 严格单次使用
200
+ ├─ 签发 256-bit 长期 token
201
+ ├─ 落盘只存 tokenHash(SHA-256)
202
+ └─ 下发 { kind:'paired', token, device } ← 仅此一次,不会再下发
203
+
204
+ [iOS] token 写入 Keychain
205
+
206
+ └─ 后续所有连接:
207
+ Authorization: Bearer <token> (推荐)
208
+ 或 Sec-WebSocket-Protocol: dsh-mobile-v1, dsh-auth.<token>
209
+ ```
210
+
211
+ ### 4.2 核心代码:签发与单次使用
212
+
213
+ ```js
214
+ claimPairing(code, clientDeviceId) {
215
+ prunePairings()
216
+ if (typeof code !== 'string' || code === '') return undefined
217
+ const codeHash = digest(code)
218
+ const pairing = pairings.get(codeHash)
219
+ if (!pairing) return undefined
220
+
221
+ // 先删除再做后续工作:即使之后 socket upgrade 失败,这个码也已作废
222
+ pairings.delete(codeHash)
223
+
224
+ const token = crypto.randomBytes(32).toString('base64url')
225
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
226
+
227
+ // 同一台 iOS 重新配对时复用已有设备记录,只轮换凭证,不产生重复行
228
+ let device = normalizedClientDeviceId
229
+ ? devices.find((c) => !c.revokedAt && c.clientDeviceId === normalizedClientDeviceId)
230
+ : undefined
231
+ if (device) {
232
+ device.name = pairing.name
233
+ device.tokenHash = digest(token) // 只存摘要
234
+ device.clientDeviceId = normalizedClientDeviceId
235
+ } else {
236
+ device = {
237
+ id: pairing.id,
238
+ name: pairing.name,
239
+ clientDeviceId: normalizedClientDeviceId,
240
+ tokenHash: digest(token), // 只存摘要
241
+ createdAt: Date.now(),
242
+ lastSeenAt: null,
243
+ revokedAt: null,
244
+ }
245
+ devices.push(device)
246
+ }
247
+ save()
248
+ return { device: publicDevice(device, 0), token } // token 只在此刻返回
249
+ }
250
+ ```
251
+
252
+ 注意 `pairings.delete(codeHash)` 的位置——**在做任何其他工作之前**。这样即使后续 WebSocket 握手失败,这个配对码也已经作废了,不存在"失败可重试所以能被反复尝试"的窗口。
253
+
254
+ ### 4.3 核心代码:凭证校验
255
+
256
+ ```js
257
+ function digest(secret) {
258
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
259
+ }
260
+
261
+ // 定长 + timing-safe 比较,不泄漏比较进度
262
+ function safeEqualHex(left, right) {
263
+ if (typeof left !== 'string' || typeof right !== 'string') return false
264
+ const a = Buffer.from(left, 'hex')
265
+ const b = Buffer.from(right, 'hex')
266
+ return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
267
+ }
268
+
269
+ authenticate(token, clientDeviceId) {
270
+ if (typeof token !== 'string' || token === '') return undefined
271
+ const tokenHash = digest(token)
272
+ const device = devices.find(
273
+ (c) => !c.revokedAt && safeEqualHex(c.tokenHash, tokenHash))
274
+ // ... 老设备补绑 clientDeviceId 的迁移逻辑
275
+ return device ? publicDevice(device, online.get(device.id) || 0) : undefined
276
+ }
277
+ ```
278
+
279
+ ### 4.4 凭证提取:为什么 token 不走 URL
280
+
281
+ ```js
282
+ function extractCredential(req, allowQueryToken) {
283
+ // ① 首选 Authorization 头,且严格校验长度(32 字节 base64url = 43 字符)
284
+ const authorization = req.headers.authorization
285
+ if (typeof authorization === 'string') {
286
+ const match = /^Bearer\s+([A-Za-z0-9_-]{43})$/i.exec(authorization.trim())
287
+ if (match) return { kind: 'token', value: match[1] }
288
+ }
289
+
290
+ // ② 次选 WebSocket 子协议(不会进入常见的 URL access log)
291
+ for (const protocol of parseProtocols(req)) {
292
+ if (protocol.startsWith('dsh-auth.')) return { kind: 'token', value: protocol.slice(9) }
293
+ if (protocol.startsWith('dsh-pair.')) return { kind: 'pairing', value: protocol.slice(9) }
294
+ }
295
+
296
+ // ③ 一次性配对码允许走 query(兼容性);长期 token 默认禁止
297
+ const query = new URL(req.url || '/', 'http://localhost').searchParams
298
+ const pairing = query.get('pairingCode')
299
+ if (pairing) return { kind: 'pairing', value: pairing }
300
+ const token = allowQueryToken && query.get('token')
301
+ if (token) return { kind: 'token', value: token }
302
+ }
303
+ ```
304
+
305
+ 长期 token 默认禁止放在 URL query(`allowQueryToken: false`),因为 query 会进入反向代理 access log、浏览器历史、APM 监控系统。而一次性配对码允许走 query——它单次使用 + 5 分钟过期,威胁窗口极小。
306
+
307
+ 还有一处容易被忽略的防护:**密钥绝不回显为协商结果**。鉴权信息可以搭在子协议里传,但服务端握手时只回固定值:
308
+
309
+ ```js
310
+ handleProtocols(protocols) {
311
+ return protocols.has('dsh-mobile-v1') ? 'dsh-mobile-v1' : false
312
+ }
313
+ ```
314
+
315
+ ### 4.5 `X-DSH-Device-ID` 不是凭证
316
+
317
+ 这点文档里反复强调过。它是客户端在 Keychain 里持久保存的安装级随机 UUID,唯一作用是**重新配对时复用同一条可信设备记录**,避免每次重连都刷出一堆重复设备行。它不能替代配对码或 token 完成鉴权:
318
+
319
+ ```js
320
+ function extractClientDeviceId(req) {
321
+ const value = req.headers['x-dsh-device-id']
322
+ if (typeof value !== 'string') return undefined
323
+ const normalized = value.trim()
324
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
325
+ }
326
+ ```
327
+
328
+ 配对时**必须**携带它,缺失直接 400 拒绝(提示升级客户端)。
329
+
330
+ ### 4.6 关于 Base64URL 的诚实标注
331
+
332
+ 配对载荷用 Base64URL 编码,源码注释写得很直白:
333
+
334
+ > Base64URL is copy-safe and QR-safe (`+`, `/`, and `=` never appear), but is **encoding rather than encryption**; secrecy still comes from the short TTL and single-use pairing code.
335
+
336
+ 没有把 Base64 包装成"加密",而是说清楚"保密性来自短 TTL 和单次使用"。这种诚实的注释比任何安全声明都可靠——它让后来的维护者知道**安全边界到底在哪**。
337
+
338
+ ### 4.7 其余配对相关的安全措施
339
+
340
+ - **默认关闭 + 自愈**:网关默认 `false`;手动开启后 5 分钟内无设备连上会自动关闭("忘记关掉"是最常见的人为漏洞)
341
+ - **文件权限**:设备文件 `0600`、目录 `0700`、临时文件 + `rename` 原子写入
342
+ - **格式迁移**:旧开发版的明文 token 会被自动 hash,下次保存时清除明文
343
+ - **管理面隔离**:`/mgw/*` 默认只接受 loopback;写操作额外要求同源(防 CSRF);请求体上限 16 KiB
344
+ - **即时吊销**:删记录 + close code `4003` 踢掉现有连接,token 永久失效
345
+ - **日志抑制**:失败尝试按 30 秒窗口聚合,避免日志被刷爆,同时保留 remote / origin / user-agent 便于溯源
346
+
347
+ 约定的 close code,客户端可以据此写状态机:
348
+
349
+ | 信号 | 含义 | 客户端应做 |
350
+ |---|---|---|
351
+ | `4003` | 鉴权被重新开启 / 设备被吊销 | 用 token 重连,失败则重新配对 |
352
+ | `4004` | 移动网关已关闭 | **停止自动重连** |
353
+ | HTTP `503` | 网关未开启 | 停止高频重连,等用户开启 |
354
+ | HTTP `401` | 凭证缺失 / 无效 / 被吊销 | 清除 Keychain,进入重新配对 |
355
+
356
+ ---
357
+
358
+ ## 五、插件是怎么挂上去的
359
+
360
+ DSH 用 Cordis 的依赖注入容器编排"服务行"。插件通过 `package.json` 的 `dsh.bundle.patch` 声明一个组合树补丁:
361
+
362
+ ```yaml
363
+ # cordis.patch.yml
364
+ - insert:
365
+ - id: mobile-gateway
366
+ name: 'dsh-plugin-mobile-gateway'
367
+ config:
368
+ gatewayEnabled: false # 默认关闭
369
+ requireAuth: true # 默认强制鉴权
370
+ adminLoopbackOnly: true # 管理面仅本机
371
+ lanEnabled: true
372
+ lanHost: 0.0.0.0
373
+ lanPort: 3081
374
+ ```
375
+
376
+ 同时声明硬依赖,Cordis 保证服务就绪后才激活它:
377
+
378
+ ```js
379
+ const plugin = {
380
+ name: 'mobile-gateway',
381
+ Config,
382
+ inject: ['webServer', 'apiProxy', 'typertGateway', 'agentDefaultModel'],
383
+ apply(ctx, config) { /* ... */ },
384
+ }
385
+ ```
386
+
387
+ 配置 schema 里的默认值全部站在安全一侧,注释写明了理由:
388
+
389
+ ```js
390
+ // Secure by default: installing the bundle must never create an
391
+ // unauthenticated network control plane.
392
+ const Config = Schema.object({
393
+ path: Schema.string().default('/ws/mobile'),
394
+ requireAuth: Schema.boolean().default(true),
395
+ gatewayEnabled: Schema.boolean().default(false),
396
+ gatewayWaitTimeoutMs: Schema.natural().min(30_000).max(30 * 60 * 1000).default(300_000),
397
+ adminLoopbackOnly: Schema.boolean().default(true),
398
+ pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(300_000),
399
+ allowQueryToken: Schema.boolean().default(false),
400
+ lanEnabled: Schema.boolean().default(false),
401
+ lanHost: Schema.string().default('0.0.0.0'),
402
+ lanPort: Schema.natural().min(1).max(65535).default(3081),
403
+ })
404
+ ```
405
+
406
+ 浏览器侧的管理面板(`lib/client.js`)用 `React.createElement` 写成,**没有 JSX、没有打包步骤**,只往两个 slot 插东西,不替换任何官方 UI 座位:
407
+
408
+ ```js
409
+ function apply(ctx) {
410
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
411
+ { name: 'sidebar.footer.action', id: 'mobile-gateway-devices', order: 80, label: '移动设备' },
412
+ (props) => React.createElement(FooterButton, props),
413
+ ))
414
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
415
+ { name: 'shell.overlay', id: 'mobile-gateway-devices-panel', order: 100, label: '移动设备' },
416
+ () => React.createElement(OverlayEntry),
417
+ ))
418
+ }
419
+ ```
420
+
421
+ ---
422
+
423
+ ## 六、安装方式
424
+
425
+ ### 1. 安装插件(无需下载源码)
426
+
427
+ 确保本机已能正常运行 DSH,然后:
428
+
429
+ ```bash
430
+ dsh plugin --profile web add dsh-plugin-mobile-gateway
431
+ ```
432
+
433
+ 这条命令会从 npm 获取插件、安装依赖,并把它加入 `web` profile 的 bundle 列表。**不需要 clone 仓库,不需要 `pnpm install`。**
434
+
435
+ 固定版本或直接装 GitHub 版:
436
+
437
+ ```bash
438
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.2
439
+ dsh plugin --profile web add github:Clarklevis1995/dsh-plugin-mobile-gateway
440
+ ```
441
+
442
+ ### 2. 重启 WebUI(最容易踩的坑)
443
+
444
+ 插件组合树**只在 WebUI 启动时加载**。刷新浏览器无效,必须停掉 `dsh web` 再重新启动。
445
+
446
+ 启动前可以先验证插件是否进了组合配置:
447
+
448
+ ```bash
449
+ dsh --profile web --dump-config | grep -A3 mobile-gateway
450
+ ```
451
+
452
+ 启动后左侧边栏底部出现"移动设备"入口,即安装成功。
453
+
454
+ ### 3. 局域网连接(零配置)
455
+
456
+ 重启后插件自动开一个 `3081` 监听。打开"移动设备"面板,"WebSocket 地址"会自动填好:
457
+
458
+ ```text
459
+ ws://<运行 DSH 的电脑私有 IP>:3081/ws/mobile
460
+ ```
461
+
462
+ 需要注意:
463
+
464
+ - 手机和电脑必须在**可互访**的同一私有网络(访客 Wi-Fi 通常开了客户端隔离,不行)
465
+ - 系统防火墙询问时,允许 Node 接收私有网络入站连接(TCP `3081`)
466
+ - VPN 场景下,确认 VPN 分配的是 RFC 1918 私有地址
467
+
468
+ ### 4. 配对 iOS 客户端
469
+
470
+ 1. 面板里填设备名称(如 `iPhone`),保持"设备鉴权"开启
471
+ 2. 开启"允许移动设备连接"
472
+ 3. 点击"生成配对二维码"
473
+ 4. iOS 客户端首页点认证按钮 → 扫码,或粘贴复制的 Base64URL 配对 Token
474
+ 5. 首次连接成功后长期凭证写入 Keychain;面板中设备显示"在线"即完成
475
+
476
+ 配对二维码**一次性使用、5 分钟过期**。之后启动 App 直接用 Keychain 凭证重连,不需要再扫码。重新配对同一套安装会复用设备身份,不会产生重复记录。
477
+
478
+ ### 5. 更新与卸载
479
+
480
+ ```bash
481
+ # 更新(插件不会自动更新)
482
+ dsh plugin --profile web remove dsh-plugin-mobile-gateway
483
+ dsh plugin --profile web add dsh-plugin-mobile-gateway
484
+ # 然后重启 dsh web
485
+
486
+ # 卸载(不会自动删除 ~/.dsh/mobile-gateway-devices.json)
487
+ dsh plugin --profile web remove dsh-plugin-mobile-gateway
488
+ ```
489
+
490
+ ### 6. 常见问题速查
491
+
492
+ | 现象 | 原因与处理 |
493
+ |---|---|
494
+ | 侧边栏没有"移动设备" | 确认用了 `--profile web`;`--dump-config` 检查组合树;**完整重启** `dsh web` |
495
+ | iOS 提示 `503` | 网关未开启,回 WebUI 打开"允许移动设备连接" |
496
+ | iOS 提示 `401` | 配对码过期 / 凭证无效 / 设备已被吊销;清除旧凭证重新配对 |
497
+ | iOS 提示 `403` | 来源不在私有网段(比如走了公网出口) |
498
+ | 真机连不上 `127.0.0.1` | 在手机上 `127.0.0.1` 指手机自己;用面板给出的私有 IP 地址 |
499
+ | 同 Wi-Fi 仍连不上 | 网络可能开了客户端隔离;放行电脑入站 TCP `3081` |
500
+ | 修改配置没生效 | 插件与组合配置只在启动时加载,需重启 WebUI |
501
+ | 需要排查服务端 | 看 `/tmp/mobile-gateway.log`,含连接 / 鉴权 / 查询 / 错误记录 |
502
+
503
+ ### 7. 源码开发
504
+
505
+ 只有需要修改插件本身时才用:
506
+
507
+ ```bash
508
+ dsh plugin --profile web add file:/absolute/path/to/dsh-plugin-mobile-gateway
509
+ ```
510
+
511
+ `file:` 是**复制安装**,改完源码必须先 remove 再 add 并重启。**不要用 `link:`**——依赖会从源码目录解析,导致 `ws` 等包找不到。
512
+
513
+ 测试:
514
+
515
+ ```bash
516
+ NODE_PATH=/path/to/dsh/node_modules node test/auth.test.mjs # 鉴权 / 配对 / 吊销
517
+ NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs # 完整协议链路
518
+ node test/lan.test.mjs # 局域网监听 / 强制鉴权 / 管理接口隔离
519
+ ```
520
+
521
+ ---
522
+
523
+ ## 七、小结
524
+
525
+ 这个插件在技术上不"炫"——没有新算法,没有花哨架构。它的价值在于把一件具体的事做干净了,并且在每个岔路口都选了更克制的那一边:
526
+
527
+ - 想要局域网访问时,**没有**去让宿主监听 `0.0.0.0`,而是自己开一个只做一件事的窄口径监听
528
+ - 给了 Debug 开关,但把它**限制在 loopback + 当前进程 + 重启复位**
529
+ - 长期凭证只在网线上出现一次,磁盘上永远只有摘要
530
+ - 公网能力没做完,就**老实标明"开发中"**,而不是先开个口子再补安全
531
+
532
+ 在 AI 让"写代码"变得越来越便宜的今天,这种克制反而是更稀缺的能力。
533
+
534
+ ---
535
+
536
+ ### 相关链接
537
+
538
+ - 项目仓库:[Clarklevis1995/dsh-plugin-mobile-gateway](https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway)
539
+ - DeepSeek Harness:[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
540
+ - 完整 WebSocket 协议:仓库内 `PROTOCOL.md`
541
+
542
+ *本文基于 dsh-plugin-mobile-gateway v0.4.2 源码撰写。公网连接能力开发中,后续会另文分享。*
@@ -0,0 +1,135 @@
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
+ ```