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 +50 -4
- package/bin/setup-ip.mjs +329 -0
- package/cordis.patch.yml +2 -0
- package/lib/client.js +7 -5
- package/lib/index.mjs +17 -2
- package/package.json +6 -1
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.
|
|
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.
|
|
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
|
-
|
|
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)。
|
package/bin/setup-ip.mjs
ADDED
|
@@ -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
|
|
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:
|
|
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 ||
|
|
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
|
+
"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",
|