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 CHANGED
@@ -1,8 +1,9 @@
1
1
  # dsh Mobile Gateway — WebSocket 协议参考
2
2
 
3
- 移动端通过一个经过设备鉴权的 WebSocket 连接与 dsh 通信:订阅 agent 实时输出、发送消息、查询会话/工作区/历史、调整会话配置。本协议由持久化插件 `dsh-plugin-mobile-gateway` 实现(v0.3.0)。
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) | 允许无凭证连接,`hello.authenticated` 为 `false` |
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 鉴权开关只在当前 DSH 进程中生效;重启后恢复配置中的 `requireAuth: true`。
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.1
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. 一条命令配置公网 IP(推荐,无需域名)
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. 在“公网 WebSocket 地址”中填写手机可以访问的地址。
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`,因为它在手机上指向手机自身。局域网或公网真机连接应通过 TLS 反向代理提供:
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
- localhost 的配对地址会强制要求 `wss://`,以免一次性配对信息和长期连接暴露在明文网络中。
119
+ 只有 localhost、`.local` 主机名及 RFC 1918/链路本地私有地址允许使用 `ws://`。其他地址会强制要求 `wss://`,以免一次性配对信息和长期连接暴露在公网明文网络中。
108
120
 
109
- ### 4. 配对 iOS 客户端
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` 在手机上不是电脑;配置手机可访问的 `wss://` 地址 |
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
- setPublicUrl((current) => current || statusData.publicUrl || inferredUrl(statusData.wsPath))
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 ? '已开启,仅允许可信设备连接' : '已关闭,仅限 Debug 阶段使用')),
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 }, '公网 WebSocket 地址'),
325
- React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => setPublicUrl(event.target.value), placeholder: 'wss://gateway.example.com/ws/mobile', spellCheck: false }),
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' } }, '公网地址必须使用 wss://。二维码中的配对码只能使用一次,并在 5 分钟内过期。')),
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:' && !isLocalHostname(url.hostname)) throw badRequest('publicUrl must use wss:// for non-localhost endpoints')
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 disposeUpgrade = webServer.registerUpgrade({
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
- if (requireAuth && !device) {
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.1",
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",