@sparkelf/dsh-plugin-mobile-gateway 0.8.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/LICENSE +21 -0
- package/PROTOCOL.md +1180 -0
- package/README.md +281 -0
- package/bin/setup-ip.mjs +474 -0
- package/cordis.patch.yml +44 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/docs/assets/public-access-ui.png +0 -0
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -0
- package/docs/dsh-rc2-mobile-integration.md +155 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/docs/session-agent-preset-app-integration.md +93 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -0
- package/helper/dsh_mobile_gateway_helper.py +227 -0
- package/lib/client.js +520 -0
- package/lib/devices.js +209 -0
- package/lib/dsh-host-adapter.mjs +466 -0
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +3197 -0
- package/lib/session-follower.mjs +136 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +50 -0
package/bin/setup-ip.mjs
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
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 { fileURLToPath } 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
|
+
const HELPER_SOURCE = fileURLToPath(new URL('../helper/dsh_mobile_gateway_helper.py', import.meta.url))
|
|
22
|
+
const HELPER_INSTALL = '/usr/local/libexec/dsh-mobile-gateway-helper'
|
|
23
|
+
const HELPER_SERVICE = '/etc/systemd/system/dsh-mobile-gateway-helper.service'
|
|
24
|
+
const HELPER_SOCKET = '/run/dsh-mobile-gateway/helper.sock'
|
|
25
|
+
|
|
26
|
+
function currentPackageSpec() {
|
|
27
|
+
const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
28
|
+
if (typeof manifest.name !== 'string' || !manifest.name || typeof manifest.version !== 'string' || !manifest.version) {
|
|
29
|
+
throw new Error('package manifest is missing name or version')
|
|
30
|
+
}
|
|
31
|
+
return `${manifest.name}@${manifest.version}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pluginInstallArgs(packageSpec = currentPackageSpec()) {
|
|
35
|
+
return [
|
|
36
|
+
'plugin', '--profile', 'web', 'add', packageSpec,
|
|
37
|
+
`--config.minimum-release-age-exclude=${packageSpec}`,
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function printHelp() {
|
|
42
|
+
console.log(`Usage:
|
|
43
|
+
dsh-plugin-mobile-gateway setup [--ip <public IPv4>] [--port 3080] [--email <address>] [--yes]
|
|
44
|
+
dsh-plugin-mobile-gateway init
|
|
45
|
+
dsh-plugin-mobile-gateway setup-helper
|
|
46
|
+
dsh-plugin-mobile-gateway remove-helper [--yes]
|
|
47
|
+
dsh-plugin-mobile-gateway status
|
|
48
|
+
dsh-plugin-mobile-gateway remove [--yes]
|
|
49
|
+
|
|
50
|
+
The setup command supports Ubuntu/Debian servers. It keeps DSH on 127.0.0.1,
|
|
51
|
+
publishes only /ws/mobile through Nginx on 443, obtains a short-lived trusted
|
|
52
|
+
Let's Encrypt IP certificate, and installs automatic renewal.`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseArgs(argv) {
|
|
56
|
+
const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'setup'
|
|
57
|
+
const values = { command, port: 3080, yes: false, ip: '', email: '' }
|
|
58
|
+
const args = command === argv[0] ? argv.slice(1) : argv
|
|
59
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
60
|
+
const arg = args[index]
|
|
61
|
+
if (arg === '--yes' || arg === '-y') values.yes = true
|
|
62
|
+
else if (arg === '--ip') values.ip = args[++index] || ''
|
|
63
|
+
else if (arg === '--port') values.port = Number(args[++index])
|
|
64
|
+
else if (arg === '--email') values.email = args[++index] || ''
|
|
65
|
+
else if (arg === '--help' || arg === '-h') values.command = 'help'
|
|
66
|
+
else throw new Error(`unknown argument: ${arg}`)
|
|
67
|
+
}
|
|
68
|
+
return values
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function run(command, args, options = {}) {
|
|
72
|
+
console.log(`\n> ${command} ${args.join(' ')}`)
|
|
73
|
+
return execFileSync(command, args, { stdio: 'inherit', ...options })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function commandExists(command) {
|
|
77
|
+
try {
|
|
78
|
+
execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' })
|
|
79
|
+
return true
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeManagedFile(file, content, mode = 0o644) {
|
|
86
|
+
if (fs.existsSync(file)) {
|
|
87
|
+
const existing = fs.readFileSync(file, 'utf8')
|
|
88
|
+
if (!existing.startsWith(MARKER)) {
|
|
89
|
+
throw new Error(`refusing to overwrite unmanaged file: ${file}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o755 })
|
|
93
|
+
const temporary = `${file}.tmp-${process.pid}`
|
|
94
|
+
fs.writeFileSync(temporary, content, { mode })
|
|
95
|
+
fs.renameSync(temporary, file)
|
|
96
|
+
fs.chmodSync(file, mode)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeInstalledHelper(source, destination) {
|
|
100
|
+
if (fs.existsSync(destination)) {
|
|
101
|
+
const existing = fs.readFileSync(destination, 'utf8')
|
|
102
|
+
if (!existing.slice(0, 256).includes(MARKER)) {
|
|
103
|
+
throw new Error(`refusing to overwrite unmanaged file: ${destination}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 })
|
|
107
|
+
const temporary = `${destination}.tmp-${process.pid}`
|
|
108
|
+
fs.copyFileSync(source, temporary)
|
|
109
|
+
fs.chmodSync(temporary, 0o755)
|
|
110
|
+
fs.renameSync(temporary, destination)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function helperService(uid) {
|
|
114
|
+
return `${MARKER}
|
|
115
|
+
[Unit]
|
|
116
|
+
Description=DSH mobile gateway privileged configuration helper
|
|
117
|
+
After=network.target
|
|
118
|
+
|
|
119
|
+
[Service]
|
|
120
|
+
Type=simple
|
|
121
|
+
ExecStart=/usr/bin/python3 ${HELPER_INSTALL} --uid ${uid} --socket ${HELPER_SOCKET}
|
|
122
|
+
Restart=on-failure
|
|
123
|
+
RestartSec=2s
|
|
124
|
+
RuntimeDirectory=dsh-mobile-gateway
|
|
125
|
+
RuntimeDirectoryMode=0755
|
|
126
|
+
NoNewPrivileges=true
|
|
127
|
+
PrivateTmp=true
|
|
128
|
+
ProtectHome=true
|
|
129
|
+
ProtectSystem=full
|
|
130
|
+
ReadWritePaths=/etc/nginx /etc/dsh-mobile-gateway /etc/letsencrypt /var/lib/dsh-mobile-gateway /var/lib/letsencrypt /var/log/letsencrypt /run/dsh-mobile-gateway
|
|
131
|
+
|
|
132
|
+
[Install]
|
|
133
|
+
WantedBy=multi-user.target
|
|
134
|
+
`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function invokingUserId() {
|
|
138
|
+
const value = Number(process.env.SUDO_UID)
|
|
139
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
140
|
+
throw new Error('setup-helper must be run with sudo from the user that runs dsh web')
|
|
141
|
+
}
|
|
142
|
+
return value
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function init() {
|
|
146
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
147
|
+
throw new Error('init must run as the normal DSH user, without sudo')
|
|
148
|
+
}
|
|
149
|
+
if (!commandExists('dsh') || !commandExists('sudo')) {
|
|
150
|
+
throw new Error('init requires dsh and sudo on PATH')
|
|
151
|
+
}
|
|
152
|
+
const packageSpec = currentPackageSpec()
|
|
153
|
+
run('dsh', pluginInstallArgs(packageSpec))
|
|
154
|
+
const script = fileURLToPath(import.meta.url)
|
|
155
|
+
run('sudo', ['env', `PATH=${process.env.PATH || ''}`, process.execPath, script, 'setup-helper'])
|
|
156
|
+
console.log('\nInitialization completed. Start or restart DSH with: dsh web')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setupHelper() {
|
|
160
|
+
assertRoot()
|
|
161
|
+
const uid = invokingUserId()
|
|
162
|
+
if (!commandExists('apt-get') || !commandExists('systemctl')) {
|
|
163
|
+
throw new Error('helper installation currently supports systemd-based Ubuntu/Debian servers only')
|
|
164
|
+
}
|
|
165
|
+
run('apt-get', ['update'])
|
|
166
|
+
run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
|
|
167
|
+
if (!fs.existsSync(CERTBOT)) {
|
|
168
|
+
run('python3', ['-m', 'venv', CERTBOT_HOME])
|
|
169
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
|
|
170
|
+
}
|
|
171
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
|
|
172
|
+
for (const directory of [CONFIG_DIR, WEBROOT, '/etc/letsencrypt', '/var/lib/letsencrypt', '/var/log/letsencrypt']) {
|
|
173
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o755 })
|
|
174
|
+
}
|
|
175
|
+
writeInstalledHelper(HELPER_SOURCE, HELPER_INSTALL)
|
|
176
|
+
writeManagedFile(HELPER_SERVICE, helperService(uid))
|
|
177
|
+
writeManagedFile(RENEW_SERVICE, renewalService())
|
|
178
|
+
writeManagedFile(RENEW_TIMER, renewalTimer())
|
|
179
|
+
run('systemctl', ['daemon-reload'])
|
|
180
|
+
run('systemctl', ['enable', '--now', path.basename(HELPER_SERVICE)])
|
|
181
|
+
run('systemctl', ['restart', path.basename(HELPER_SERVICE)])
|
|
182
|
+
run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
|
|
183
|
+
console.log(`\nHelper installed for uid ${uid}. Public access can now be configured from the Mobile Devices panel.`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function removeHelper(options) {
|
|
187
|
+
assertRoot()
|
|
188
|
+
if (!await confirm('Remove the privileged mobile gateway helper? Existing Nginx configuration will be kept.', options.yes)) {
|
|
189
|
+
console.log('Cancelled.')
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (commandExists('systemctl')) {
|
|
193
|
+
try { run('systemctl', ['disable', '--now', path.basename(HELPER_SERVICE)]) } catch {}
|
|
194
|
+
}
|
|
195
|
+
for (const file of [HELPER_SERVICE, HELPER_INSTALL]) {
|
|
196
|
+
if (!fs.existsSync(file)) continue
|
|
197
|
+
const existing = fs.readFileSync(file, 'utf8')
|
|
198
|
+
if (!existing.slice(0, 256).includes(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
|
|
199
|
+
fs.rmSync(file)
|
|
200
|
+
console.log(`Removed ${file}`)
|
|
201
|
+
}
|
|
202
|
+
if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function metadataPublicIp() {
|
|
206
|
+
return new Promise((resolve) => {
|
|
207
|
+
const request = http.get({
|
|
208
|
+
host: 'metadata.tencentyun.com',
|
|
209
|
+
path: '/latest/meta-data/public-ipv4',
|
|
210
|
+
timeout: 2_500,
|
|
211
|
+
headers: { 'User-Agent': 'dsh-mobile-gateway-setup' },
|
|
212
|
+
}, (response) => {
|
|
213
|
+
let body = ''
|
|
214
|
+
response.setEncoding('utf8')
|
|
215
|
+
response.on('data', (chunk) => { body += chunk })
|
|
216
|
+
response.on('end', () => resolve(response.statusCode === 200 ? body.trim() : ''))
|
|
217
|
+
})
|
|
218
|
+
request.on('timeout', () => request.destroy())
|
|
219
|
+
request.on('error', () => resolve(''))
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function assertPublicIpv4(value) {
|
|
224
|
+
if (net.isIP(value) !== 4) throw new Error(`invalid public IPv4 address: ${value || '(empty)'}`)
|
|
225
|
+
const octets = value.split('.').map(Number)
|
|
226
|
+
const privateAddress = octets[0] === 10
|
|
227
|
+
|| octets[0] === 127
|
|
228
|
+
|| (octets[0] === 169 && octets[1] === 254)
|
|
229
|
+
|| (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
|
|
230
|
+
|| (octets[0] === 192 && octets[1] === 168)
|
|
231
|
+
|| octets[0] === 0
|
|
232
|
+
|| octets[0] >= 224
|
|
233
|
+
if (privateAddress) throw new Error(`${value} is not a public IPv4 address`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function certName(ip) {
|
|
237
|
+
return `dsh-mobile-gateway-${ip.replaceAll('.', '-')}`
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function nginxHttpConfig(ip) {
|
|
241
|
+
return `${MARKER}
|
|
242
|
+
server {
|
|
243
|
+
listen 80;
|
|
244
|
+
server_name ${ip};
|
|
245
|
+
|
|
246
|
+
location ^~ /.well-known/acme-challenge/ {
|
|
247
|
+
root ${WEBROOT};
|
|
248
|
+
default_type text/plain;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
location / { return 404; }
|
|
252
|
+
}
|
|
253
|
+
`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function nginxTlsConfig(ip, backendPort) {
|
|
257
|
+
const liveDirectory = `/etc/letsencrypt/live/${certName(ip)}`
|
|
258
|
+
return `${nginxHttpConfig(ip)}
|
|
259
|
+
server {
|
|
260
|
+
listen 443 ssl;
|
|
261
|
+
server_name ${ip};
|
|
262
|
+
server_tokens off;
|
|
263
|
+
access_log off;
|
|
264
|
+
|
|
265
|
+
ssl_certificate ${liveDirectory}/fullchain.pem;
|
|
266
|
+
ssl_certificate_key ${liveDirectory}/privkey.pem;
|
|
267
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
268
|
+
|
|
269
|
+
location = /ws/mobile {
|
|
270
|
+
if ($args != "") { return 404; }
|
|
271
|
+
proxy_pass http://127.0.0.1:${backendPort};
|
|
272
|
+
proxy_http_version 1.1;
|
|
273
|
+
proxy_set_header Host 127.0.0.1:${backendPort};
|
|
274
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
275
|
+
proxy_set_header Connection "upgrade";
|
|
276
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
277
|
+
proxy_read_timeout 3600s;
|
|
278
|
+
proxy_send_timeout 3600s;
|
|
279
|
+
proxy_buffering off;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
location / { return 404; }
|
|
283
|
+
}
|
|
284
|
+
`
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renewalService() {
|
|
288
|
+
return `${MARKER}
|
|
289
|
+
[Unit]
|
|
290
|
+
Description=Renew the DSH mobile gateway IP certificate
|
|
291
|
+
After=network-online.target nginx.service
|
|
292
|
+
|
|
293
|
+
[Service]
|
|
294
|
+
Type=oneshot
|
|
295
|
+
ExecStart=${CERTBOT} renew --quiet --deploy-hook /bin/systemctl reload nginx
|
|
296
|
+
`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renewalTimer() {
|
|
300
|
+
return `${MARKER}
|
|
301
|
+
[Unit]
|
|
302
|
+
Description=Twice-daily renewal check for the DSH mobile gateway IP certificate
|
|
303
|
+
|
|
304
|
+
[Timer]
|
|
305
|
+
OnCalendar=*-*-* 00,12:00:00
|
|
306
|
+
RandomizedDelaySec=20m
|
|
307
|
+
Persistent=true
|
|
308
|
+
|
|
309
|
+
[Install]
|
|
310
|
+
WantedBy=timers.target
|
|
311
|
+
`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function confirm(message, assumeYes) {
|
|
315
|
+
if (assumeYes) return true
|
|
316
|
+
if (!process.stdin.isTTY) throw new Error('interactive confirmation is unavailable; rerun with --yes')
|
|
317
|
+
const terminal = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
318
|
+
const answer = await terminal.question(`${message} [y/N] `)
|
|
319
|
+
terminal.close()
|
|
320
|
+
return /^y(es)?$/i.test(answer.trim())
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function assertRoot() {
|
|
324
|
+
if (typeof process.getuid === 'function' && process.getuid() !== 0) {
|
|
325
|
+
throw new Error('setup/remove must run as root; prepend sudo to the command')
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function setup(options) {
|
|
330
|
+
assertRoot()
|
|
331
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error(`invalid backend port: ${options.port}`)
|
|
332
|
+
if (options.email && !/^\S+@\S+\.\S+$/.test(options.email)) throw new Error(`invalid email address: ${options.email}`)
|
|
333
|
+
if (!commandExists('apt-get') || !commandExists('systemctl')) {
|
|
334
|
+
throw new Error('automatic setup currently supports systemd-based Ubuntu/Debian servers only')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const ip = options.ip || await metadataPublicIp()
|
|
338
|
+
if (!ip) throw new Error('unable to detect a Tencent Cloud public IP; rerun with --ip <public IPv4>')
|
|
339
|
+
assertPublicIpv4(ip)
|
|
340
|
+
|
|
341
|
+
console.log(`
|
|
342
|
+
Public IPv4: ${ip}
|
|
343
|
+
DSH backend: http://127.0.0.1:${options.port}
|
|
344
|
+
Public endpoint: wss://${ip}/ws/mobile
|
|
345
|
+
|
|
346
|
+
Before continuing, open inbound TCP ports 80 and 443 in the Tencent Cloud
|
|
347
|
+
security group. Only /ws/mobile will be proxied; the WebUI and /mgw remain private.`)
|
|
348
|
+
if (!await confirm('Install Nginx/Certbot and configure this public endpoint?', options.yes)) {
|
|
349
|
+
console.log('Cancelled.')
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
run('apt-get', ['update'])
|
|
354
|
+
run('apt-get', ['install', '-y', 'nginx', 'python3', 'python3-venv'])
|
|
355
|
+
if (!fs.existsSync(CERTBOT)) {
|
|
356
|
+
run('python3', ['-m', 'venv', CERTBOT_HOME])
|
|
357
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'pip'])
|
|
358
|
+
}
|
|
359
|
+
run(path.join(CERTBOT_HOME, 'bin/pip'), ['install', '--upgrade', 'certbot>=5.4,<6'])
|
|
360
|
+
|
|
361
|
+
fs.mkdirSync(path.join(WEBROOT, '.well-known/acme-challenge'), { recursive: true, mode: 0o755 })
|
|
362
|
+
writeManagedFile(NGINX_CONFIG, nginxHttpConfig(ip))
|
|
363
|
+
run('nginx', ['-t'])
|
|
364
|
+
run('systemctl', ['enable', '--now', 'nginx'])
|
|
365
|
+
run('systemctl', ['reload', 'nginx'])
|
|
366
|
+
|
|
367
|
+
const certificateArgs = [
|
|
368
|
+
'certonly', '--non-interactive', '--agree-tos',
|
|
369
|
+
'--preferred-profile', 'shortlived',
|
|
370
|
+
'--webroot', '--webroot-path', WEBROOT,
|
|
371
|
+
'--ip-address', ip,
|
|
372
|
+
'--cert-name', certName(ip),
|
|
373
|
+
'--keep-until-expiring',
|
|
374
|
+
]
|
|
375
|
+
if (options.email) certificateArgs.push('--email', options.email)
|
|
376
|
+
else certificateArgs.push('--register-unsafely-without-email')
|
|
377
|
+
run(CERTBOT, certificateArgs)
|
|
378
|
+
|
|
379
|
+
writeManagedFile(NGINX_CONFIG, nginxTlsConfig(ip, options.port))
|
|
380
|
+
writeManagedFile(PUBLIC_URL_FILE, `${MARKER}\nwss://${ip}/ws/mobile\n`)
|
|
381
|
+
writeManagedFile(RENEW_SERVICE, renewalService())
|
|
382
|
+
writeManagedFile(RENEW_TIMER, renewalTimer())
|
|
383
|
+
run('nginx', ['-t'])
|
|
384
|
+
run('systemctl', ['reload', 'nginx'])
|
|
385
|
+
run('systemctl', ['daemon-reload'])
|
|
386
|
+
run('systemctl', ['enable', '--now', path.basename(RENEW_TIMER)])
|
|
387
|
+
|
|
388
|
+
console.log(`
|
|
389
|
+
Setup completed.
|
|
390
|
+
|
|
391
|
+
1. Restart dsh web so the plugin reads ${PUBLIC_URL_FILE}.
|
|
392
|
+
2. Open “移动设备”, enable the gateway, and generate a pairing QR code.
|
|
393
|
+
3. The iOS client should connect to wss://${ip}/ws/mobile.
|
|
394
|
+
|
|
395
|
+
Check later with:
|
|
396
|
+
sudo npx dsh-plugin-mobile-gateway status`)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function status() {
|
|
400
|
+
const endpoint = fs.existsSync(PUBLIC_URL_FILE)
|
|
401
|
+
? fs.readFileSync(PUBLIC_URL_FILE, 'utf8').split(/\r?\n/).find((line) => line.startsWith('wss://'))
|
|
402
|
+
: ''
|
|
403
|
+
console.log(`Public endpoint: ${endpoint || 'not configured'}`)
|
|
404
|
+
console.log(`Nginx config: ${fs.existsSync(NGINX_CONFIG) ? NGINX_CONFIG : 'not installed'}`)
|
|
405
|
+
console.log(`Renewal timer: ${fs.existsSync(RENEW_TIMER) ? RENEW_TIMER : 'not installed'}`)
|
|
406
|
+
if (commandExists('systemctl') && fs.existsSync(RENEW_TIMER)) {
|
|
407
|
+
try { run('systemctl', ['--no-pager', 'status', path.basename(RENEW_TIMER)]) } catch {}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function remove(options) {
|
|
412
|
+
assertRoot()
|
|
413
|
+
if (!await confirm('Remove the managed Nginx endpoint and renewal timer? Certificates and installed packages will be kept.', options.yes)) {
|
|
414
|
+
console.log('Cancelled.')
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
if (commandExists('systemctl')) {
|
|
418
|
+
try { run('systemctl', ['disable', '--now', path.basename(RENEW_TIMER)]) } catch {}
|
|
419
|
+
}
|
|
420
|
+
for (const file of [NGINX_CONFIG, PUBLIC_URL_FILE, RENEW_SERVICE, RENEW_TIMER]) {
|
|
421
|
+
if (!fs.existsSync(file)) continue
|
|
422
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
423
|
+
if (!content.startsWith(MARKER)) throw new Error(`refusing to remove unmanaged file: ${file}`)
|
|
424
|
+
fs.rmSync(file)
|
|
425
|
+
console.log(`Removed ${file}`)
|
|
426
|
+
}
|
|
427
|
+
if (commandExists('systemctl')) run('systemctl', ['daemon-reload'])
|
|
428
|
+
if (commandExists('nginx')) {
|
|
429
|
+
run('nginx', ['-t'])
|
|
430
|
+
run('systemctl', ['reload', 'nginx'])
|
|
431
|
+
}
|
|
432
|
+
console.log('Public IP endpoint removed. Existing certificates and packages were left intact.')
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export {
|
|
436
|
+
assertPublicIpv4,
|
|
437
|
+
certName,
|
|
438
|
+
currentPackageSpec,
|
|
439
|
+
nginxHttpConfig,
|
|
440
|
+
nginxTlsConfig,
|
|
441
|
+
parseArgs,
|
|
442
|
+
pluginInstallArgs,
|
|
443
|
+
helperService,
|
|
444
|
+
renewalService,
|
|
445
|
+
renewalTimer,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isMainModule(argvEntry) {
|
|
449
|
+
if (!argvEntry) return false
|
|
450
|
+
try {
|
|
451
|
+
return fs.realpathSync(argvEntry) === fs.realpathSync(fileURLToPath(import.meta.url))
|
|
452
|
+
} catch {
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export { isMainModule }
|
|
458
|
+
|
|
459
|
+
if (isMainModule(process.argv[1])) {
|
|
460
|
+
try {
|
|
461
|
+
const options = parseArgs(process.argv.slice(2))
|
|
462
|
+
if (options.command === 'help') printHelp()
|
|
463
|
+
else if (options.command === 'init') await init()
|
|
464
|
+
else if (options.command === 'setup-helper') setupHelper()
|
|
465
|
+
else if (options.command === 'remove-helper') await removeHelper(options)
|
|
466
|
+
else if (options.command === 'setup') await setup(options)
|
|
467
|
+
else if (options.command === 'status') status()
|
|
468
|
+
else if (options.command === 'remove') await remove(options)
|
|
469
|
+
else throw new Error(`unknown command: ${options.command}`)
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.error(`\nError: ${error && error.message ? error.message : String(error)}`)
|
|
472
|
+
process.exitCode = 1
|
|
473
|
+
}
|
|
474
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# dsh-plugin-mobile-gateway bundle patch.
|
|
2
|
+
# Applied after dsh-base and dsh-web-app: inserts the persistent /ws/mobile
|
|
3
|
+
# gateway row into the host plane (it consumes `webServer`, the Host Remote
|
|
4
|
+
# Gateway and the `session/event` feed, and publishes no service of its own).
|
|
5
|
+
- insert:
|
|
6
|
+
- id: mobile-gateway
|
|
7
|
+
name: '@sparkelf/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
|
+
# Optional startup mode: disabled | temporary | persistent.
|
|
13
|
+
# Saved management-UI choices take precedence over startup config.
|
|
14
|
+
# gatewayMode: persistent
|
|
15
|
+
# gatewayName: Home PC
|
|
16
|
+
# gatewayStateFile: /path/to/instance-gateway.json
|
|
17
|
+
# Optional additional reachable addresses for this SAME gateway:
|
|
18
|
+
# endpoints:
|
|
19
|
+
# - wss://gateway.example.com/ws/mobile
|
|
20
|
+
gatewayWaitTimeoutMs: 300000
|
|
21
|
+
requireAuth: true
|
|
22
|
+
adminLoopbackOnly: true
|
|
23
|
+
pairingTtlMs: 300000
|
|
24
|
+
# DSH WebUI remains loopback-only on 3080. The plugin owns this
|
|
25
|
+
# authenticated, WebSocket-only LAN listener for phones on the same
|
|
26
|
+
# private network.
|
|
27
|
+
lanEnabled: true
|
|
28
|
+
lanHost: 0.0.0.0
|
|
29
|
+
lanPort: 3081
|
|
30
|
+
# The public WebSocket address phones dial from outside the LAN. A plain file so a
|
|
31
|
+
# deployment that terminates TLS elsewhere (a reverse proxy, an frp tunnel) can state its
|
|
32
|
+
# own entry without installing the privileged Helper; `setup` writes this same path.
|
|
33
|
+
publicUrlFile: /etc/dsh-mobile-gateway/public-url
|
|
34
|
+
|
|
35
|
+
# The gateway's `search` query channel needs the full-text session index,
|
|
36
|
+
# which dsh-web-app deliberately ships as `openAt: never` (search disabled).
|
|
37
|
+
# This later layer opts the deployment into on-demand opening: the node:sqlite
|
|
38
|
+
# import and the in-memory index stay deferred until the first search call,
|
|
39
|
+
# so startup is unaffected. A patch replaces the row's whole config, so both
|
|
40
|
+
# keys are restated here.
|
|
41
|
+
- id: session-query-sqlite
|
|
42
|
+
config:
|
|
43
|
+
path: ':memory:'
|
|
44
|
+
openAt: first-search
|
|
Binary file
|
|
Binary file
|
|
Binary file
|