@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.
@@ -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
+ }
@@ -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