dsh-plugin-mobile-gateway 0.3.0 → 0.4.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.
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.3.0
27
+ dsh plugin --profile web add dsh-plugin-mobile-gateway@0.4.0
28
28
  ```
29
29
 
30
30
  也可以不经过 npm,直接安装 GitHub 版本:
@@ -45,7 +45,46 @@ dsh --profile web --dump-config | grep -A3 mobile-gateway
45
45
 
46
46
  启动 WebUI 后,左侧边栏底部应出现“移动设备”入口。
47
47
 
48
- ### 3. 配置手机可访问的 WebSocket 地址
48
+ ### 3. 一条命令配置公网 IP(推荐,无需域名)
49
+
50
+ 如果 Harness 部署在带固定公网 IPv4 的腾讯云 Ubuntu/Debian 服务器上,先在腾讯云安全组中放行入站 TCP `80` 和 `443`,然后在服务器执行:
51
+
52
+ ```bash
53
+ sudo npx --yes dsh-plugin-mobile-gateway setup
54
+ ```
55
+
56
+ 安装器会自动从腾讯云实例元数据读取公网 IPv4,并完成:
57
+
58
+ - 安装 Nginx 和独立 Python 虚拟环境中的 Certbot
59
+ - 为公网 IP 申请受系统信任的短期 TLS 证书
60
+ - 只把 `wss://公网IP/ws/mobile` 代理到 `127.0.0.1:3080`
61
+ - 对普通 HTTP、WebUI、`/mgw/*` 管理接口和其他路径返回 `404`
62
+ - 创建每天两次运行的 systemd 证书续期任务
63
+ - 把公网地址写入 `/etc/dsh-mobile-gateway/public-url`,插件启动时自动读取
64
+
65
+ 因此 DSH 仍然只需监听 `127.0.0.1:3080`,不需要把 3080 端口暴露到公网,也不需要 Tunnel、域名或手写 Nginx 配置。
66
+
67
+ 邮箱是可选项;希望接收 Let's Encrypt 账户通知时可追加 `--email you@example.com`。如果无法从腾讯云元数据识别公网地址,或 DSH 使用了其他本地端口:
68
+
69
+ ```bash
70
+ sudo npx --yes dsh-plugin-mobile-gateway setup \
71
+ --ip 203.0.113.10 \
72
+ --port 3080 \
73
+ --email you@example.com
74
+ ```
75
+
76
+ 执行完成后重启 `dsh web`,打开 WebUI 的“移动设备”面板。公网 WebSocket 地址会自动显示为 `wss://公网IP/ws/mobile`;开启移动网关并生成二维码即可。
77
+
78
+ 查看状态或移除安装器生成的公网入口:
79
+
80
+ ```bash
81
+ sudo npx --yes dsh-plugin-mobile-gateway status
82
+ sudo npx --yes dsh-plugin-mobile-gateway remove
83
+ ```
84
+
85
+ `remove` 只删除本插件生成的 Nginx 配置、地址文件和续期任务,不卸载软件,也不删除已有证书。IP 证书有效期约 6 天,必须保持 80 端口可达以便自动续期。相关能力来自 [Let's Encrypt 的短期 IP 地址证书](https://letsencrypt.org/2026/03/11/shorter-certs-certbot/);公网 IP 自动识别使用[腾讯云实例元数据](https://cloud.tencent.com/document/product/213/17940)。
86
+
87
+ #### 已有域名或反向代理
49
88
 
50
89
  打开“移动设备”面板:
51
90
 
@@ -130,9 +169,9 @@ dsh plugin --profile web remove dsh-plugin-mobile-gateway
130
169
 
131
170
  卸载插件不会自动删除 `~/.dsh/mobile-gateway-devices.json`。
132
171
 
133
- ## 公网部署
172
+ ## 手动公网部署
134
173
 
135
- DSH HTTP Server 本身不负责 TLS。公网入口应由 Nginx、Caddy 或其他反向代理提供 HTTPS/WSS,并且只公开 WebSocket 路由,不要公开 `/mgw` 管理接口或整个未鉴权 WebUI。
174
+ 不使用一键安装器时,公网入口应由 Nginx、Caddy 或其他反向代理提供 HTTPS/WSS,并且只公开 WebSocket 路由,不要公开 `/mgw` 管理接口或整个未鉴权 WebUI。
136
175
 
137
176
  Nginx 核心配置示例:
138
177
 
@@ -158,6 +197,7 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
158
197
  requireAuth: true
159
198
  adminLoopbackOnly: true
160
199
  publicUrl: wss://gateway.example.com/ws/mobile
200
+ publicUrlFile: /etc/dsh-mobile-gateway/public-url
161
201
  pairingTtlMs: 300000
162
202
  allowQueryToken: false
163
203
  ```
@@ -172,6 +212,9 @@ TLS 证书、域名、防火墙、访问日志保护和代理层速率限制由
172
212
  | iOS 连接提示 `503` | 移动网关尚未开启,回到 WebUI 开启“允许移动设备连接” |
173
213
  | iOS 连接提示 `401` | 配对码过期、长期凭证无效或设备已被吊销;删除客户端旧凭证后重新配对 |
174
214
  | 真机无法连接 `127.0.0.1` | `127.0.0.1` 在手机上不是电脑;配置手机可访问的 `wss://` 地址 |
215
+ | 一键配置无法识别公网 IP | 确认命令运行在腾讯云 CVM 内,或通过 `--ip <公网 IPv4>` 显式指定 |
216
+ | Certbot 申请或续期失败 | 确认腾讯云安全组和服务器防火墙都允许入站 TCP 80/443,并确认公网 IP 没有变化 |
217
+ | 想检查自动续期 | 执行 `sudo systemctl status dsh-mobile-gateway-cert-renew.timer` 和 `sudo npx --yes dsh-plugin-mobile-gateway status` |
175
218
  | 二维码无法再次使用 | 配对码设计为一次性且 5 分钟过期,重新生成即可 |
176
219
  | 修改配置后没有生效 | 插件与 profile composition 在启动时加载,需要重启 WebUI 进程 |
177
220
  | 需要排查服务端原因 | 查看 `/tmp/mobile-gateway.log`,其中包含连接、鉴权、查询与错误记录 |
@@ -201,6 +244,9 @@ NODE_PATH=/path/to/dsh/node_modules node test/auth.test.mjs
201
244
 
202
245
  # mock Harness + 真实 WebSocket 客户端的完整协议链路
203
246
  NODE_PATH=/path/to/dsh/node_modules node test/gateway.test.mjs
247
+
248
+ # 公网 IP 安装器的参数、Nginx 隔离与续期配置
249
+ node test/setup-ip.test.mjs
204
250
  ```
205
251
 
206
252
  版本历史与全部 WebSocket 消息类型见 [`PROTOCOL.md`](PROTOCOL.md)。
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import http from 'node:http'
5
+ import net from 'node:net'
6
+ import path from 'node:path'
7
+ import process from 'node:process'
8
+ import readline from 'node:readline/promises'
9
+ import { execFileSync } from 'node:child_process'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ const MARKER = '# Managed by dsh-plugin-mobile-gateway'
13
+ const CONFIG_DIR = '/etc/dsh-mobile-gateway'
14
+ const PUBLIC_URL_FILE = path.join(CONFIG_DIR, 'public-url')
15
+ const WEBROOT = '/var/lib/dsh-mobile-gateway/acme'
16
+ const NGINX_CONFIG = '/etc/nginx/conf.d/dsh-mobile-gateway.conf'
17
+ const CERTBOT_HOME = '/opt/dsh-mobile-gateway/certbot'
18
+ const CERTBOT = path.join(CERTBOT_HOME, 'bin/certbot')
19
+ const RENEW_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.service'
20
+ const RENEW_TIMER = '/etc/systemd/system/dsh-mobile-gateway-cert-renew.timer'
21
+
22
+ function printHelp() {
23
+ console.log(`Usage:
24
+ dsh-plugin-mobile-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
25
+ dsh-plugin-mobile-gateway status
26
+ dsh-plugin-mobile-gateway remove [--yes]
27
+
28
+ The setup command supports Ubuntu/Debian servers. It keeps DSH on 127.0.0.1,
29
+ publishes only /ws/mobile through Nginx on 443, obtains a short-lived trusted
30
+ Let's Encrypt IP certificate, and installs automatic renewal.`)
31
+ }
32
+
33
+ function parseArgs(argv) {
34
+ const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'setup'
35
+ const values = { command, port: 3080, yes: false, ip: '', email: '' }
36
+ const args = command === argv[0] ? argv.slice(1) : argv
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const arg = args[index]
39
+ if (arg === '--yes' || arg === '-y') values.yes = true
40
+ else if (arg === '--ip') values.ip = args[++index] || ''
41
+ else if (arg === '--port') values.port = Number(args[++index])
42
+ else if (arg === '--email') values.email = args[++index] || ''
43
+ else if (arg === '--help' || arg === '-h') values.command = 'help'
44
+ else throw new Error(`unknown argument: ${arg}`)
45
+ }
46
+ return values
47
+ }
48
+
49
+ function run(command, args, options = {}) {
50
+ console.log(`\n> ${command} ${args.join(' ')}`)
51
+ return execFileSync(command, args, { stdio: 'inherit', ...options })
52
+ }
53
+
54
+ function commandExists(command) {
55
+ try {
56
+ execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' })
57
+ return true
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
63
+ function writeManagedFile(file, content, mode = 0o644) {
64
+ if (fs.existsSync(file)) {
65
+ const existing = fs.readFileSync(file, 'utf8')
66
+ if (!existing.startsWith(MARKER)) {
67
+ throw new Error(`refusing to overwrite unmanaged file: ${file}`)
68
+ }
69
+ }
70
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 })
71
+ const temporary = `${file}.tmp-${process.pid}`
72
+ fs.writeFileSync(temporary, content, { mode })
73
+ fs.renameSync(temporary, file)
74
+ fs.chmodSync(file, mode)
75
+ }
76
+
77
+ function metadataPublicIp() {
78
+ return new Promise((resolve) => {
79
+ const request = http.get({
80
+ host: 'metadata.tencentyun.com',
81
+ path: '/latest/meta-data/public-ipv4',
82
+ timeout: 2_500,
83
+ headers: { 'User-Agent': 'dsh-mobile-gateway-setup' },
84
+ }, (response) => {
85
+ let body = ''
86
+ response.setEncoding('utf8')
87
+ response.on('data', (chunk) => { body += chunk })
88
+ response.on('end', () => resolve(response.statusCode === 200 ? body.trim() : ''))
89
+ })
90
+ request.on('timeout', () => request.destroy())
91
+ request.on('error', () => resolve(''))
92
+ })
93
+ }
94
+
95
+ function assertPublicIpv4(value) {
96
+ if (net.isIP(value) !== 4) throw new Error(`invalid public IPv4 address: ${value || '(empty)'}`)
97
+ const octets = value.split('.').map(Number)
98
+ const privateAddress = octets[0] === 10
99
+ || octets[0] === 127
100
+ || (octets[0] === 169 && octets[1] === 254)
101
+ || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
102
+ || (octets[0] === 192 && octets[1] === 168)
103
+ || octets[0] === 0
104
+ || octets[0] >= 224
105
+ if (privateAddress) throw new Error(`${value} is not a public IPv4 address`)
106
+ }
107
+
108
+ function certName(ip) {
109
+ return `dsh-mobile-gateway-${ip.replaceAll('.', '-')}`
110
+ }
111
+
112
+ function nginxHttpConfig(ip) {
113
+ return `${MARKER}
114
+ server {
115
+ listen 80;
116
+ server_name ${ip};
117
+
118
+ location ^~ /.well-known/acme-challenge/ {
119
+ root ${WEBROOT};
120
+ default_type text/plain;
121
+ }
122
+
123
+ location / { return 404; }
124
+ }
125
+ `
126
+ }
127
+
128
+ function nginxTlsConfig(ip, backendPort) {
129
+ const liveDirectory = `/etc/letsencrypt/live/${certName(ip)}`
130
+ return `${nginxHttpConfig(ip)}
131
+ server {
132
+ listen 443 ssl;
133
+ server_name ${ip};
134
+ server_tokens off;
135
+ access_log off;
136
+
137
+ ssl_certificate ${liveDirectory}/fullchain.pem;
138
+ ssl_certificate_key ${liveDirectory}/privkey.pem;
139
+ ssl_protocols TLSv1.2 TLSv1.3;
140
+
141
+ location = /ws/mobile {
142
+ if ($args != "") { return 404; }
143
+ proxy_pass http://127.0.0.1:${backendPort};
144
+ proxy_http_version 1.1;
145
+ proxy_set_header Host 127.0.0.1:${backendPort};
146
+ proxy_set_header Upgrade $http_upgrade;
147
+ proxy_set_header Connection "upgrade";
148
+ proxy_set_header X-Forwarded-Proto https;
149
+ proxy_read_timeout 3600s;
150
+ proxy_send_timeout 3600s;
151
+ proxy_buffering off;
152
+ }
153
+
154
+ location / { return 404; }
155
+ }
156
+ `
157
+ }
158
+
159
+ function renewalService() {
160
+ return `${MARKER}
161
+ [Unit]
162
+ Description=Renew the DSH mobile gateway IP certificate
163
+ After=network-online.target nginx.service
164
+
165
+ [Service]
166
+ Type=oneshot
167
+ ExecStart=${CERTBOT} renew --quiet --deploy-hook /bin/systemctl reload nginx
168
+ `
169
+ }
170
+
171
+ function renewalTimer() {
172
+ return `${MARKER}
173
+ [Unit]
174
+ Description=Twice-daily renewal check for the DSH mobile gateway IP certificate
175
+
176
+ [Timer]
177
+ OnCalendar=*-*-* 00,12:00:00
178
+ RandomizedDelaySec=20m
179
+ Persistent=true
180
+
181
+ [Install]
182
+ WantedBy=timers.target
183
+ `
184
+ }
185
+
186
+ async function confirm(message, assumeYes) {
187
+ if (assumeYes) return true
188
+ if (!process.stdin.isTTY) throw new Error('interactive confirmation is unavailable; rerun with --yes')
189
+ const terminal = readline.createInterface({ input: process.stdin, output: process.stdout })
190
+ const answer = await terminal.question(`${message} [y/N] `)
191
+ terminal.close()
192
+ return /^y(es)?$/i.test(answer.trim())
193
+ }
194
+
195
+ function assertRoot() {
196
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
197
+ throw new Error('setup/remove must run as root; prepend sudo to the command')
198
+ }
199
+ }
200
+
201
+ async function setup(options) {
202
+ assertRoot()
203
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error(`invalid backend port: ${options.port}`)
204
+ if (options.email && !/^\S+@\S+\.\S+$/.test(options.email)) throw new Error(`invalid email address: ${options.email}`)
205
+ if (!commandExists('apt-get') || !commandExists('systemctl')) {
206
+ throw new Error('automatic setup currently supports systemd-based Ubuntu/Debian servers only')
207
+ }
208
+
209
+ const ip = options.ip || await metadataPublicIp()
210
+ if (!ip) throw new Error('unable to detect a Tencent Cloud public IP; rerun with --ip <public IPv4>')
211
+ assertPublicIpv4(ip)
212
+
213
+ console.log(`
214
+ Public IPv4: ${ip}
215
+ DSH backend: http://127.0.0.1:${options.port}
216
+ Public endpoint: wss://${ip}/ws/mobile
217
+
218
+ Before continuing, open inbound TCP ports 80 and 443 in the Tencent Cloud
219
+ security group. Only /ws/mobile will be proxied; the WebUI and /mgw remain private.`)
220
+ if (!await confirm('Install Nginx/Certbot and configure this public endpoint?', options.yes)) {
221
+ console.log('Cancelled.')
222
+ return
223
+ }
224
+
225
+ run('apt-get', ['update'])
226
+ run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
227
+ if (!fs.existsSync(CERTBOT)) {
228
+ run('python3', ['-m', 'venv', CERTBOT_HOME])
229
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
230
+ }
231
+ run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
232
+
233
+ fs.mkdirSync(path.join(WEBROOT, '.well-known/acme-challenge'), { recursive: true, mode: 0o755 })
234
+ writeManagedFile(NGINX_CONFIG, nginxHttpConfig(ip))
235
+ run('nginx', ['-t'])
236
+ run('systemctl', ['enable', '--now', 'nginx'])
237
+ run('systemctl', ['reload', 'nginx'])
238
+
239
+ const certificateArgs = [
240
+ 'certonly', '--non-interactive', '--agree-tos',
241
+ '--preferred-profile', 'shortlived',
242
+ '--webroot', '--webroot-path', WEBROOT,
243
+ '--ip-address', ip,
244
+ '--cert-name', certName(ip),
245
+ '--keep-until-expiring',
246
+ ]
247
+ if (options.email) certificateArgs.push('--email', options.email)
248
+ else certificateArgs.push('--register-unsafely-without-email')
249
+ run(CERTBOT, certificateArgs)
250
+
251
+ writeManagedFile(NGINX_CONFIG, nginxTlsConfig(ip, options.port))
252
+ writeManagedFile(PUBLIC_URL_FILE, `${MARKER}\nwss://${ip}/ws/mobile\n`)
253
+ writeManagedFile(RENEW_SERVICE, renewalService())
254
+ writeManagedFile(RENEW_TIMER, renewalTimer())
255
+ run('nginx', ['-t'])
256
+ run('systemctl', ['reload', 'nginx'])
257
+ run('systemctl', ['daemon-reload'])
258
+ run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
259
+
260
+ console.log(`
261
+ Setup completed.
262
+
263
+ 1. Restart dsh web so the plugin reads ${PUBLIC_URL_FILE}.
264
+ 2. Open “移动设备”, enable the gateway, and generate a pairing QR code.
265
+ 3. The iOS client should connect to wss://${ip}/ws/mobile.
266
+
267
+ Check later with:
268
+ sudo npx dsh-plugin-mobile-gateway status`)
269
+ }
270
+
271
+ function status() {
272
+ const endpoint = fs.existsSync(PUBLIC_URL_FILE)
273
+ ? fs.readFileSync(PUBLIC_URL_FILE, 'utf8').split(/\r?\n/).find((line) => line.startsWith('wss://'))
274
+ : ''
275
+ console.log(`Public endpoint: ${endpoint || 'not configured'}`)
276
+ console.log(`Nginx config: ${fs.existsSync(NGINX_CONFIG) ? NGINX_CONFIG : 'not installed'}`)
277
+ console.log(`Renewal timer: ${fs.existsSync(RENEW_TIMER) ? RENEW_TIMER : 'not installed'}`)
278
+ if (commandExists('systemctl') && fs.existsSync(RENEW_TIMER)) {
279
+ try { run('systemctl', ['--no-pager', 'status', path.basename(RENEW_TIMER)]) } catch {}
280
+ }
281
+ }
282
+
283
+ async function remove(options) {
284
+ assertRoot()
285
+ if (!await confirm('Remove the managed Nginx endpoint and renewal timer? Certificates and installed packages will be kept.', options.yes)) {
286
+ console.log('Cancelled.')
287
+ return
288
+ }
289
+ if (commandExists('systemctl')) {
290
+ try { run('systemctl', ['disable', '--now', path.basename(RENEW_TIMER)]) } catch {}
291
+ }
292
+ for (const file of [NGINX_CONFIG, PUBLIC_URL_FILE, RENEW_SERVICE, RENEW_TIMER]) {
293
+ if (!fs.existsSync(file)) continue
294
+ const content = fs.readFileSync(file, 'utf8')
295
+ if (!content.startsWith(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
296
+ fs.rmSync(file)
297
+ console.log(`Removed ${file}`)
298
+ }
299
+ if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
300
+ if (commandExists('nginx')) {
301
+ run('nginx', ['-t'])
302
+ run('systemctl', ['reload', 'nginx'])
303
+ }
304
+ console.log('Public IP endpoint removed. Existing certificates and packages were left intact.')
305
+ }
306
+
307
+ export {
308
+ assertPublicIpv4,
309
+ certName,
310
+ nginxHttpConfig,
311
+ nginxTlsConfig,
312
+ parseArgs,
313
+ renewalService,
314
+ renewalTimer,
315
+ }
316
+
317
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
318
+ try {
319
+ const options = parseArgs(process.argv.slice(2))
320
+ if (options.command === 'help') printHelp()
321
+ else if (options.command === 'setup') await setup(options)
322
+ else if (options.command === 'status') status()
323
+ else if (options.command === 'remove') await remove(options)
324
+ else throw new Error(`unknown command: ${options.command}`)
325
+ } catch (error) {
326
+ console.error(`\nError: ${error && error.message ? error.message : String(error)}`)
327
+ process.exitCode = 1
328
+ }
329
+ }
package/cordis.patch.yml CHANGED
@@ -13,6 +13,8 @@
13
13
  requireAuth: true
14
14
  adminLoopbackOnly: true
15
15
  pairingTtlMs: 300000
16
+ # Written automatically by `sudo npx dsh-plugin-mobile-gateway setup`.
17
+ publicUrlFile: /etc/dsh-mobile-gateway/public-url
16
18
 
17
19
  # The gateway's `search` query channel needs the full-text session index,
18
20
  # which dsh-web-app deliberately ships as `openAt: never` (search disabled).
package/lib/client.js CHANGED
@@ -168,16 +168,18 @@ window.__ModuleLoader__.load({
168
168
  return () => { active = false; window.clearInterval(timer) }
169
169
  }, [refresh])
170
170
 
171
+ const createPairing = async (endpoint) => request('/mgw/pair', {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ name: deviceName, publicUrl: endpoint }),
175
+ })
176
+
171
177
  const pair = async () => {
172
178
  setBusy(true)
173
179
  setError(null)
174
180
  setPairingTokenCopied(false)
175
181
  try {
176
- const data = await request('/mgw/pair', {
177
- method: 'POST',
178
- headers: { 'Content-Type': 'application/json' },
179
- body: JSON.stringify({ name: deviceName, publicUrl }),
180
- })
182
+ const data = await createPairing(publicUrl)
181
183
  setQr(data)
182
184
  } catch (cause) {
183
185
  setError(cause.message)
package/lib/index.mjs CHANGED
@@ -87,6 +87,7 @@ const Config = Schema.object({
87
87
  deviceFile: Schema.string().default(''),
88
88
  pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
89
89
  allowQueryToken: Schema.boolean().default(false),
90
+ publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
90
91
  })
91
92
 
92
93
  function log(line) {
@@ -773,6 +774,7 @@ const plugin = {
773
774
  deviceFile: '',
774
775
  pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
775
776
  allowQueryToken: false,
777
+ publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
776
778
  ...(config || {}),
777
779
  }
778
780
  const wsPath = options.path
@@ -800,6 +802,19 @@ const plugin = {
800
802
  let connectedSinceEnabled = false
801
803
  let lastAuthRejectLogAt = 0
802
804
  let suppressedAuthRejects = 0
805
+ const configuredPublicUrl = () => {
806
+ if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
807
+ if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
808
+ try {
809
+ return fs.readFileSync(options.publicUrlFile, 'utf8')
810
+ .split(/\r?\n/)
811
+ .map((line) => line.trim())
812
+ .find((line) => line && !line.startsWith('#')) || ''
813
+ } catch (error) {
814
+ if (!error || error.code !== 'ENOENT') log(`unable to read publicUrlFile: ${error && error.message ? error.message : String(error)}`)
815
+ return ''
816
+ }
817
+ }
803
818
 
804
819
  const setGatewayEnabled = (enabled, reason) => {
805
820
  if (waitTimer) clearTimeout(waitTimer)
@@ -864,7 +879,7 @@ const plugin = {
864
879
  waitExpiresAt,
865
880
  connectedClients: clients.size,
866
881
  wsPath,
867
- publicUrl: options.publicUrl || null,
882
+ publicUrl: configuredPublicUrl() || null,
868
883
  pairingTtlMs: options.pairingTtlMs,
869
884
  queryTokenAllowed: !!options.allowQueryToken,
870
885
  })
@@ -898,7 +913,7 @@ const plugin = {
898
913
  }
899
914
  const body = await readBody(req)
900
915
  const name = typeof body.name === 'string' ? body.name : undefined
901
- const publicUrl = normalizePublicUrl(body.publicUrl || options.publicUrl, req, wsPath)
916
+ const publicUrl = normalizePublicUrl(body.publicUrl || configuredPublicUrl(), req, wsPath)
902
917
  const pairing = registry.createPairing(name)
903
918
  const payload = {
904
919
  version: 2,
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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": [
7
+ "bin",
7
8
  "lib",
8
9
  "docs",
9
10
  "cordis.patch.yml",
@@ -22,6 +23,10 @@
22
23
  "ws": "^8.18.0",
23
24
  "qrcode": "^1.5.4"
24
25
  },
26
+ "bin": "bin/setup-ip.mjs",
27
+ "scripts": {
28
+ "test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs"
29
+ },
25
30
  "exports": {
26
31
  ".": "./lib/index.mjs",
27
32
  "./client": "./lib/client.js",