dsh-plugin-mobile-gateway 0.4.1 → 0.4.2
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 +4 -3
- package/README.md +27 -7
- package/cordis.patch.yml +6 -0
- package/lib/client.js +23 -6
- package/lib/index.mjs +127 -8
- package/package.json +2 -2
package/PROTOCOL.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# dsh Mobile Gateway — WebSocket 协议参考
|
|
2
2
|
|
|
3
|
-
移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.
|
|
3
|
+
移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.4.2)。
|
|
4
4
|
|
|
5
5
|
- **本机端点**:`ws://127.0.0.1:3080/ws/mobile`(与 dsh web GUI 同端口)
|
|
6
|
+
- **局域网端点**:`ws://<电脑的私有局域网 IP>:3081/ws/mobile`(插件独立监听,只提供经过鉴权的 WebSocket)
|
|
6
7
|
- **公网端点**:必须由 TLS 反向代理提供 `wss://<域名>/ws/mobile`
|
|
7
8
|
- **帧格式**:全部为 JSON 文本帧(UTF-8)
|
|
8
9
|
- **连接即推送**:连上后服务端立刻发送一条 `hello`,之后 agent 输出以 `event` 帧实时推送
|
|
@@ -43,12 +44,12 @@ WebUI 中有两个互相独立的开关。它们是本机管理设置,iOS 客
|
|
|
43
44
|
|---|---|---|
|
|
44
45
|
| 关闭 | 任意 | WebSocket Upgrade 返回 `503 Service Unavailable` |
|
|
45
46
|
| 开启 | 开启(默认) | 必须使用一次性配对码或长期设备 token,否则返回 `401 Unauthorized` |
|
|
46
|
-
| 开启 | 关闭(仅 Debug) |
|
|
47
|
+
| 开启 | 关闭(仅 Debug) | 仅 DSH 本机监听允许无凭证连接,`hello.authenticated` 为 `false`;独立局域网监听仍返回 `401` |
|
|
47
48
|
|
|
48
49
|
- 移动网关默认关闭。手动开启后,默认 5 分钟内没有客户端成功建立连接就自动关闭。
|
|
49
50
|
- 关闭移动网关会关闭现有连接,WebSocket close code 为 `4004`。
|
|
50
51
|
- 从 Debug 模式重新开启鉴权时,所有无凭证连接会被关闭,close code 为 `4003`。
|
|
51
|
-
- Debug
|
|
52
|
+
- Debug 鉴权开关只影响 DSH 自带的本机监听,并且只在当前 DSH 进程中生效;独立局域网监听始终强制设备鉴权。
|
|
52
53
|
|
|
53
54
|
#### 首次配对
|
|
54
55
|
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ dsh plugin --profile web add dsh-plugin-mobile-gateway
|
|
|
24
24
|
如果希望固定版本,可以在包名后指定版本号:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.
|
|
27
|
+
dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.2
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
也可以不经过 npm,直接安装 GitHub 版本:
|
|
@@ -45,7 +45,19 @@ dsh --profile web --dump-config | grep -A3 mobile-gateway
|
|
|
45
45
|
|
|
46
46
|
启动 WebUI 后,左侧边栏底部应出现“移动设备”入口。
|
|
47
47
|
|
|
48
|
-
### 3.
|
|
48
|
+
### 3. 局域网直接连接(无需域名、证书或反向代理)
|
|
49
|
+
|
|
50
|
+
插件安装并重启 `dsh web` 后,会额外创建一个仅用于移动端的局域网监听:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
ws://<运行 DSH 的电脑局域网 IP>:3081/ws/mobile
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
打开 WebUI 的“移动设备”面板时,插件会自动检测电脑的私有 IPv4,并优先把可用地址填入“WebSocket 地址”。开启移动网关、生成二维码并用 iOS 客户端扫码即可。电脑和手机需要位于可互访的同一局域网;如果系统防火墙询问是否允许 Node 接收入站连接,请允许私有网络访问 TCP `3081`。
|
|
57
|
+
|
|
58
|
+
该端口只接受来自本机或私有网络地址的 `/ws/mobile` WebSocket Upgrade,不提供 WebUI,也不暴露 `/mgw/*` 管理接口。局域网监听始终要求设备配对鉴权,WebUI 中的 Debug 鉴权开关不会关闭它的鉴权。
|
|
59
|
+
|
|
60
|
+
### 4. 一条命令配置公网 IP(推荐,无需域名)
|
|
49
61
|
|
|
50
62
|
如果 Harness 部署在带固定公网 IPv4 的腾讯云 Ubuntu/Debian 服务器上,先在腾讯云安全组中放行入站 TCP `80` 和 `443`,然后在服务器执行:
|
|
51
63
|
|
|
@@ -90,7 +102,7 @@ sudo npx --yes dsh-plugin-mobile-gateway remove
|
|
|
90
102
|
|
|
91
103
|
1. 开启“允许移动设备连接”。
|
|
92
104
|
2. 保持“设备鉴权”开启。
|
|
93
|
-
3.
|
|
105
|
+
3. 在“WebSocket 地址”中填写手机可以访问的地址。
|
|
94
106
|
|
|
95
107
|
本机浏览器调试可以使用:
|
|
96
108
|
|
|
@@ -98,15 +110,15 @@ sudo npx --yes dsh-plugin-mobile-gateway remove
|
|
|
98
110
|
ws://127.0.0.1:3080/ws/mobile
|
|
99
111
|
```
|
|
100
112
|
|
|
101
|
-
真机不能使用 `127.0.0.1
|
|
113
|
+
真机不能使用 `127.0.0.1`,因为它在手机上指向手机自身。同一私有局域网可使用插件自动提供的 `ws://局域网IP:3081/ws/mobile`;公网连接则应通过 TLS 反向代理提供:
|
|
102
114
|
|
|
103
115
|
```text
|
|
104
116
|
wss://gateway.example.com/ws/mobile
|
|
105
117
|
```
|
|
106
118
|
|
|
107
|
-
|
|
119
|
+
只有 localhost、`.local` 主机名及 RFC 1918/链路本地私有地址允许使用 `ws://`。其他地址会强制要求 `wss://`,以免一次性配对信息和长期连接暴露在公网明文网络中。
|
|
108
120
|
|
|
109
|
-
###
|
|
121
|
+
### 5. 配对 iOS 客户端
|
|
110
122
|
|
|
111
123
|
1. 在 WebUI 中填写设备名称,例如 `iPhone`。
|
|
112
124
|
2. 点击“生成配对二维码”。
|
|
@@ -196,6 +208,10 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
|
|
|
196
208
|
gatewayWaitTimeoutMs: 300000
|
|
197
209
|
requireAuth: true
|
|
198
210
|
adminLoopbackOnly: true
|
|
211
|
+
lanEnabled: true
|
|
212
|
+
lanHost: 0.0.0.0
|
|
213
|
+
lanPort: 3081
|
|
214
|
+
lanAdvertiseHost: ''
|
|
199
215
|
publicUrl: wss://gateway.example.com/ws/mobile
|
|
200
216
|
publicUrlFile: /etc/dsh-mobile-gateway/public-url
|
|
201
217
|
pairingTtlMs: 300000
|
|
@@ -211,7 +227,8 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
|
|
|
211
227
|
| WebUI 没有“移动设备”入口 | 确认使用 `--profile web` 安装;执行 `--dump-config` 检查组合树,然后完整重启 `dsh web` |
|
|
212
228
|
| iOS 连接提示 `503` | 移动网关尚未开启,回到 WebUI 开启“允许移动设备连接” |
|
|
213
229
|
| iOS 连接提示 `401` | 配对码过期、长期凭证无效或设备已被吊销;删除客户端旧凭证后重新配对 |
|
|
214
|
-
| 真机无法连接 `127.0.0.1` | `127.0.0.1`
|
|
230
|
+
| 真机无法连接 `127.0.0.1` | `127.0.0.1` 在手机上不是电脑;局域网使用面板自动显示的 `ws://私有IP:3081/ws/mobile`,公网使用 `wss://` 地址 |
|
|
231
|
+
| 同一 Wi-Fi 仍无法连接局域网地址 | 确认手机与电脑所在网络允许设备互访,并放行电脑入站 TCP 3081;访客 Wi-Fi 通常会启用客户端隔离 |
|
|
215
232
|
| 一键配置无法识别公网 IP | 确认命令运行在腾讯云 CVM 内,或通过 `--ip <公网 IPv4>` 显式指定 |
|
|
216
233
|
| Certbot 申请或续期失败 | 确认腾讯云安全组和服务器防火墙都允许入站 TCP 80/443,并确认公网 IP 没有变化 |
|
|
217
234
|
| 想检查自动续期 | 执行 `sudo systemctl status dsh-mobile-gateway-cert-renew.timer` 和 `sudo npx --yes dsh-plugin-mobile-gateway status` |
|
|
@@ -247,6 +264,9 @@ NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs
|
|
|
247
264
|
|
|
248
265
|
# 公网 IP 安装器的参数、Nginx 隔离与续期配置
|
|
249
266
|
node test/setup-ip.test.mjs
|
|
267
|
+
|
|
268
|
+
# 独立局域网监听、强制鉴权与管理接口隔离
|
|
269
|
+
node test/lan.test.mjs
|
|
250
270
|
```
|
|
251
271
|
|
|
252
272
|
版本历史与全部 WebSocket 消息类型见 [`PROTOCOL.md`](PROTOCOL.md)。
|
package/cordis.patch.yml
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
requireAuth: true
|
|
14
14
|
adminLoopbackOnly: true
|
|
15
15
|
pairingTtlMs: 300000
|
|
16
|
+
# DSH WebUI remains loopback-only on 3080. The plugin owns this
|
|
17
|
+
# authenticated, WebSocket-only LAN listener for phones on the same
|
|
18
|
+
# private network.
|
|
19
|
+
lanEnabled: true
|
|
20
|
+
lanHost: 0.0.0.0
|
|
21
|
+
lanPort: 3081
|
|
16
22
|
# Written automatically by `sudo npx dsh-plugin-mobile-gateway setup`.
|
|
17
23
|
publicUrlFile: /etc/dsh-mobile-gateway/public-url
|
|
18
24
|
|
package/lib/client.js
CHANGED
|
@@ -125,6 +125,7 @@ window.__ModuleLoader__.load({
|
|
|
125
125
|
const [deviceNoticeError, setDeviceNoticeError] = React.useState(false)
|
|
126
126
|
const [revokingIds, setRevokingIds] = React.useState(() => new Set())
|
|
127
127
|
const revokingIdsRef = React.useRef(new Set())
|
|
128
|
+
const automaticUrlRef = React.useRef('')
|
|
128
129
|
|
|
129
130
|
const refresh = React.useCallback(async () => {
|
|
130
131
|
try {
|
|
@@ -140,7 +141,15 @@ window.__ModuleLoader__.load({
|
|
|
140
141
|
setDevices((current) => JSON.stringify(current) === JSON.stringify(nextDevices) ? current : nextDevices)
|
|
141
142
|
}
|
|
142
143
|
setStatus((current) => JSON.stringify(current) === JSON.stringify(statusData) ? current : statusData)
|
|
143
|
-
|
|
144
|
+
const lanUrl = statusData.lan && Array.isArray(statusData.lan.urls) ? statusData.lan.urls[0] : ''
|
|
145
|
+
const preferredUrl = statusData.publicUrl || lanUrl || inferredUrl(statusData.wsPath)
|
|
146
|
+
setPublicUrl((current) => {
|
|
147
|
+
if (!current || current === automaticUrlRef.current) {
|
|
148
|
+
automaticUrlRef.current = preferredUrl
|
|
149
|
+
return preferredUrl
|
|
150
|
+
}
|
|
151
|
+
return current
|
|
152
|
+
})
|
|
144
153
|
return true
|
|
145
154
|
} catch (cause) {
|
|
146
155
|
setError(cause.message)
|
|
@@ -303,7 +312,7 @@ window.__ModuleLoader__.load({
|
|
|
303
312
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
304
313
|
React.createElement('div', null,
|
|
305
314
|
React.createElement('strong', null, '设备鉴权'),
|
|
306
|
-
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.requireAuth ? '已开启,仅允许可信设备连接' : '
|
|
315
|
+
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.requireAuth ? '已开启,仅允许可信设备连接' : '已关闭,仅影响本机 Debug 入口')),
|
|
307
316
|
React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.requireAuth ? '关闭设备鉴权' : '开启设备鉴权' },
|
|
308
317
|
React.createElement('input', {
|
|
309
318
|
type: 'checkbox',
|
|
@@ -318,15 +327,23 @@ window.__ModuleLoader__.load({
|
|
|
318
327
|
React.createElement('span', { style: { ...styles.switchTrack, background: status && status.requireAuth ? colors.success : colors.danger } }),
|
|
319
328
|
React.createElement('span', { style: { ...styles.switchKnob, left: status && status.requireAuth ? 21 : 3 } }))),
|
|
320
329
|
status && !status.requireAuth
|
|
321
|
-
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug
|
|
330
|
+
? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
|
|
322
331
|
: null),
|
|
323
332
|
React.createElement('section', { style: styles.card },
|
|
324
|
-
React.createElement('label', { style: styles.label }, '
|
|
325
|
-
React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => setPublicUrl(event.target.value), placeholder: '
|
|
333
|
+
React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
|
|
334
|
+
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 }),
|
|
335
|
+
status && status.lan && status.lan.enabled
|
|
336
|
+
? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
|
|
337
|
+
status.lan.error
|
|
338
|
+
? `局域网监听失败:${status.lan.error}`
|
|
339
|
+
: status.lan.listening
|
|
340
|
+
? `局域网入口已开启${status.lan.urls && status.lan.urls.length ? `:${status.lan.urls.join('、')}` : `,端口 ${status.lan.port}`}`
|
|
341
|
+
: '局域网入口启动中…')
|
|
342
|
+
: null,
|
|
326
343
|
React.createElement('label', { style: styles.label }, '设备名称'),
|
|
327
344
|
React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
|
|
328
345
|
React.createElement('button', { style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) ? .65 : 1 }, disabled: busy || !(status && status.gatewayEnabled), onClick: pair }, busy ? '正在生成…' : status && status.gatewayEnabled ? '生成配对二维码' : '请先开启移动网关'),
|
|
329
|
-
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '
|
|
346
|
+
React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网可直接使用私有地址的 ws://;公网地址仍必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
|
|
330
347
|
qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
|
|
331
348
|
React.createElement('strong', null, '使用 iOS 客户端扫码'),
|
|
332
349
|
React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
|
package/lib/index.mjs
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
// { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
|
|
58
58
|
import fs from 'node:fs'
|
|
59
59
|
import fsp from 'node:fs/promises'
|
|
60
|
+
import http from 'node:http'
|
|
60
61
|
import os from 'node:os'
|
|
61
62
|
import path from 'node:path'
|
|
62
63
|
import crypto from 'node:crypto'
|
|
@@ -88,6 +89,10 @@ const Config = Schema.object({
|
|
|
88
89
|
pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
|
|
89
90
|
allowQueryToken: Schema.boolean().default(false),
|
|
90
91
|
publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
|
|
92
|
+
lanEnabled: Schema.boolean().default(false),
|
|
93
|
+
lanHost: Schema.string().default('0.0.0.0'),
|
|
94
|
+
lanPort: Schema.natural().min(1).max(65535).default(3081),
|
|
95
|
+
lanAdvertiseHost: Schema.string().default(''),
|
|
91
96
|
})
|
|
92
97
|
|
|
93
98
|
function log(line) {
|
|
@@ -665,7 +670,7 @@ function extractClientDeviceId(req) {
|
|
|
665
670
|
}
|
|
666
671
|
|
|
667
672
|
function rejectUpgrade(socket, status, message) {
|
|
668
|
-
const reason = status === 401 ? 'Unauthorized' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
673
|
+
const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
|
|
669
674
|
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
|
|
670
675
|
socket.destroy()
|
|
671
676
|
}
|
|
@@ -675,6 +680,47 @@ function isLocalHostname(hostname) {
|
|
|
675
680
|
return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
|
|
676
681
|
}
|
|
677
682
|
|
|
683
|
+
function isPrivateNetworkHostname(hostname) {
|
|
684
|
+
let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
|
|
685
|
+
if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
|
|
686
|
+
if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
|
|
687
|
+
const octets = normalized.split('.').map((part) => Number(part))
|
|
688
|
+
if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
|
|
689
|
+
return octets[0] === 10 ||
|
|
690
|
+
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
|
691
|
+
(octets[0] === 192 && octets[1] === 168) ||
|
|
692
|
+
(octets[0] === 169 && octets[1] === 254)
|
|
693
|
+
}
|
|
694
|
+
return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function privateLanAddresses() {
|
|
698
|
+
const physical = []
|
|
699
|
+
const fallback = []
|
|
700
|
+
const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
|
|
701
|
+
for (const [name, records] of Object.entries(os.networkInterfaces())) {
|
|
702
|
+
for (const record of records || []) {
|
|
703
|
+
if (!record || record.internal || record.family !== 'IPv4' || !isPrivateNetworkHostname(record.address)) continue
|
|
704
|
+
const target = virtualInterface.test(name) ? fallback : physical
|
|
705
|
+
if (!target.includes(record.address)) target.push(record.address)
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return physical.length ? physical : fallback
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function lanWebSocketUrls(options, wsPath, boundPort) {
|
|
712
|
+
if (!options.lanEnabled) return []
|
|
713
|
+
const advertised = typeof options.lanAdvertiseHost === 'string' ? options.lanAdvertiseHost.trim() : ''
|
|
714
|
+
const hosts = advertised
|
|
715
|
+
? [advertised]
|
|
716
|
+
: options.lanHost && options.lanHost !== '0.0.0.0' && options.lanHost !== '::'
|
|
717
|
+
? [options.lanHost]
|
|
718
|
+
: privateLanAddresses()
|
|
719
|
+
return hosts
|
|
720
|
+
.filter((host) => isPrivateNetworkHostname(host))
|
|
721
|
+
.map((host) => `ws://${host.includes(':') ? `[${host}]` : host}:${boundPort}${wsPath}`)
|
|
722
|
+
}
|
|
723
|
+
|
|
678
724
|
function badRequest(message) {
|
|
679
725
|
const error = new Error(message)
|
|
680
726
|
error.status = 400
|
|
@@ -690,9 +736,9 @@ function normalizePublicUrl(value, req, wsPath) {
|
|
|
690
736
|
const url = new URL(raw)
|
|
691
737
|
if (url.protocol === 'https:') url.protocol = 'wss:'
|
|
692
738
|
if (url.protocol === 'http:') url.protocol = 'ws:'
|
|
693
|
-
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost)')
|
|
739
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
|
|
694
740
|
if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
|
|
695
|
-
if (url.protocol === 'ws:' && !
|
|
741
|
+
if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
|
|
696
742
|
return url.toString()
|
|
697
743
|
}
|
|
698
744
|
|
|
@@ -775,12 +821,22 @@ const plugin = {
|
|
|
775
821
|
pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
|
|
776
822
|
allowQueryToken: false,
|
|
777
823
|
publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
|
|
824
|
+
lanEnabled: false,
|
|
825
|
+
lanHost: '0.0.0.0',
|
|
826
|
+
lanPort: 3081,
|
|
827
|
+
lanAdvertiseHost: '',
|
|
778
828
|
...(config || {}),
|
|
779
829
|
}
|
|
780
830
|
const wsPath = options.path
|
|
781
831
|
if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
|
|
782
832
|
throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
|
|
783
833
|
}
|
|
834
|
+
if (options.lanEnabled === true && (typeof options.lanHost !== 'string' || !options.lanHost.trim())) {
|
|
835
|
+
throw new Error('mobile-gateway: lanHost must be a non-empty listen address')
|
|
836
|
+
}
|
|
837
|
+
if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
|
|
838
|
+
throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
|
|
839
|
+
}
|
|
784
840
|
let requireAuth = options.requireAuth !== false
|
|
785
841
|
const adminLoopbackOnly = options.adminLoopbackOnly !== false
|
|
786
842
|
const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
|
|
@@ -802,6 +858,10 @@ const plugin = {
|
|
|
802
858
|
let connectedSinceEnabled = false
|
|
803
859
|
let lastAuthRejectLogAt = 0
|
|
804
860
|
let suppressedAuthRejects = 0
|
|
861
|
+
let lanServer = null
|
|
862
|
+
let lanListening = false
|
|
863
|
+
let lanListenError = null
|
|
864
|
+
let lanBoundPort = options.lanPort
|
|
805
865
|
const configuredPublicUrl = () => {
|
|
806
866
|
if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
|
|
807
867
|
if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
|
|
@@ -880,6 +940,15 @@ const plugin = {
|
|
|
880
940
|
connectedClients: clients.size,
|
|
881
941
|
wsPath,
|
|
882
942
|
publicUrl: configuredPublicUrl() || null,
|
|
943
|
+
lan: {
|
|
944
|
+
enabled: options.lanEnabled === true,
|
|
945
|
+
listening: lanListening,
|
|
946
|
+
host: options.lanHost,
|
|
947
|
+
port: lanBoundPort,
|
|
948
|
+
urls: lanWebSocketUrls(options, wsPath, lanBoundPort),
|
|
949
|
+
requireAuth: true,
|
|
950
|
+
error: lanListenError,
|
|
951
|
+
},
|
|
883
952
|
pairingTtlMs: options.pairingTtlMs,
|
|
884
953
|
queryTokenAllowed: !!options.allowQueryToken,
|
|
885
954
|
})
|
|
@@ -960,9 +1029,7 @@ const plugin = {
|
|
|
960
1029
|
})
|
|
961
1030
|
log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
|
|
962
1031
|
|
|
963
|
-
const
|
|
964
|
-
path: wsPath,
|
|
965
|
-
handler(req, socket, head) {
|
|
1032
|
+
const handleMobileUpgrade = (req, socket, head, transport = {}) => {
|
|
966
1033
|
if (!gatewayEnabled) {
|
|
967
1034
|
rejectUpgrade(socket, 503, 'mobile gateway is disabled')
|
|
968
1035
|
return
|
|
@@ -988,7 +1055,11 @@ const plugin = {
|
|
|
988
1055
|
device = registry.authenticate(credential.value, extractClientDeviceId(req))
|
|
989
1056
|
}
|
|
990
1057
|
|
|
991
|
-
|
|
1058
|
+
// The separately exposed LAN listener is always authenticated. The
|
|
1059
|
+
// management UI's debug auth switch only affects the loopback DSH
|
|
1060
|
+
// listener and can never turn a LAN endpoint into an open control
|
|
1061
|
+
// plane.
|
|
1062
|
+
if ((requireAuth || transport.lan === true) && !device) {
|
|
992
1063
|
logAuthRejected(req)
|
|
993
1064
|
rejectUpgrade(socket, 401, 'missing or invalid device credential')
|
|
994
1065
|
return
|
|
@@ -1062,16 +1133,63 @@ const plugin = {
|
|
|
1062
1133
|
ws.send(JSON.stringify({
|
|
1063
1134
|
kind: 'hello',
|
|
1064
1135
|
protocol: 2,
|
|
1065
|
-
port: webServer.port,
|
|
1136
|
+
port: transport.port || webServer.port,
|
|
1066
1137
|
clients: clients.size,
|
|
1067
1138
|
authenticated: !!device,
|
|
1068
1139
|
...(device ? { device: { id: device.id, name: device.name } } : {}),
|
|
1069
1140
|
}))
|
|
1070
1141
|
})
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const disposeUpgrade = webServer.registerUpgrade({
|
|
1145
|
+
path: wsPath,
|
|
1146
|
+
handler(req, socket, head) {
|
|
1147
|
+
handleMobileUpgrade(req, socket, head, { port: webServer.port })
|
|
1071
1148
|
},
|
|
1072
1149
|
})
|
|
1073
1150
|
log(`upgrade route registered: ${wsPath}`)
|
|
1074
1151
|
|
|
1152
|
+
// DSH intentionally keeps its own WebUI listener on loopback. When LAN
|
|
1153
|
+
// mode is enabled, expose a second, narrowly scoped listener that accepts
|
|
1154
|
+
// only the authenticated mobile WebSocket path. It does not serve the
|
|
1155
|
+
// WebUI or any /mgw management route.
|
|
1156
|
+
if (options.lanEnabled === true) {
|
|
1157
|
+
lanServer = http.createServer((req, res) => {
|
|
1158
|
+
sendJson(res, 404, { error: 'not-found' })
|
|
1159
|
+
})
|
|
1160
|
+
lanServer.on('upgrade', (req, socket, head) => {
|
|
1161
|
+
let pathname
|
|
1162
|
+
try {
|
|
1163
|
+
pathname = new URL(req.url || '/', 'http://lan.invalid').pathname
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
rejectUpgrade(socket, 400, 'invalid WebSocket path')
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
if (pathname !== wsPath) {
|
|
1169
|
+
rejectUpgrade(socket, 404, 'not found')
|
|
1170
|
+
return
|
|
1171
|
+
}
|
|
1172
|
+
if (!req.socket || !req.socket.remoteAddress || !isPrivateNetworkHostname(req.socket.remoteAddress)) {
|
|
1173
|
+
rejectUpgrade(socket, 403, 'LAN listener accepts private-network clients only')
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1176
|
+
handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
|
|
1177
|
+
})
|
|
1178
|
+
lanServer.on('error', (error) => {
|
|
1179
|
+
lanListening = false
|
|
1180
|
+
lanListenError = error && error.message ? error.message : String(error)
|
|
1181
|
+
log(`LAN listener failed: ${lanListenError}`)
|
|
1182
|
+
})
|
|
1183
|
+
lanServer.listen(options.lanPort, options.lanHost, () => {
|
|
1184
|
+
lanListening = true
|
|
1185
|
+
lanListenError = null
|
|
1186
|
+
const address = lanServer.address()
|
|
1187
|
+
if (address && typeof address === 'object') lanBoundPort = address.port
|
|
1188
|
+
const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
|
|
1189
|
+
log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
|
|
1190
|
+
})
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1075
1193
|
const disposeEvents = ctx.on('session/event', (session, event) => {
|
|
1076
1194
|
if (clients.size === 0) return
|
|
1077
1195
|
const wire = buildWireEvent(session, event)
|
|
@@ -1089,6 +1207,7 @@ const plugin = {
|
|
|
1089
1207
|
disposeUpgrade()
|
|
1090
1208
|
disposeMgmt()
|
|
1091
1209
|
disposeEvents()
|
|
1210
|
+
if (lanServer) lanServer.close()
|
|
1092
1211
|
for (const client of clients) client.terminate()
|
|
1093
1212
|
clients.clear()
|
|
1094
1213
|
wss.close()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"bin": "bin/setup-ip.mjs",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs"
|
|
28
|
+
"test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
|
|
29
29
|
},
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./lib/index.mjs",
|