dsh-plugin-mobile-gateway 0.1.17 → 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/PROTOCOL.md +109 -6
- package/README.md +226 -30
- package/bin/setup-ip.mjs +329 -0
- package/cordis.patch.yml +10 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/lib/client.js +425 -0
- package/lib/devices.js +209 -0
- package/lib/{index.js → index.mjs} +427 -18
- package/package.json +33 -4
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
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
- insert:
|
|
6
6
|
- id: mobile-gateway
|
|
7
7
|
name: 'dsh-plugin-mobile-gateway'
|
|
8
|
+
config:
|
|
9
|
+
# Secure by default: every mobile WebSocket must authenticate with a
|
|
10
|
+
# paired device credential. Pair/revoke APIs remain local-machine only.
|
|
11
|
+
gatewayEnabled: false
|
|
12
|
+
gatewayWaitTimeoutMs: 300000
|
|
13
|
+
requireAuth: true
|
|
14
|
+
adminLoopbackOnly: true
|
|
15
|
+
pairingTtlMs: 300000
|
|
16
|
+
# Written automatically by `sudo npx dsh-plugin-mobile-gateway setup`.
|
|
17
|
+
publicUrlFile: /etc/dsh-mobile-gateway/public-url
|
|
8
18
|
|
|
9
19
|
# The gateway's `search` query channel needs the full-text session index,
|
|
10
20
|
# which dsh-web-app deliberately ships as `openAt: never` (search disabled).
|
|
Binary file
|