dsh-remote-control-plugin 0.3.5 → 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/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +170 -0
- package/index.js +218 -34
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -9,6 +9,10 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { createFabric } from './host/fabric.js'
|
|
10
10
|
import { createRelayLink } from './host/relay-link.js'
|
|
11
11
|
import { createRelay } from './host/relay-core.js'
|
|
12
|
+
import { checkRelayUrl, ensureReachable } from './host/reachability.js'
|
|
13
|
+
import { qrSvg } from './host/qr.js'
|
|
14
|
+
import { createUploadManager } from './host/uploads.js'
|
|
15
|
+
import { loadOrCreateIdentity } from './host/identity.js'
|
|
12
16
|
|
|
13
17
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
14
18
|
const WEB_DIR = path.join(HERE, 'web')
|
|
@@ -38,6 +42,7 @@ export function apply(ctx, rawConfig) {
|
|
|
38
42
|
const config = normalizeConfig(rawConfig)
|
|
39
43
|
const stateFile = path.join(dshHome(), 'remote-control.json')
|
|
40
44
|
const peersFile = path.join(dshHome(), 'remote-control-peers.json')
|
|
45
|
+
const identityFile = path.join(dshHome(), 'remote-control-identity.json')
|
|
41
46
|
const streams = new Set()
|
|
42
47
|
const published = { urls: [], code: '' }
|
|
43
48
|
const info = { port: config.port, proto: config.tls ? 'https' : 'http' }
|
|
@@ -48,7 +53,10 @@ export function apply(ctx, rawConfig) {
|
|
|
48
53
|
let fabric
|
|
49
54
|
let relayLink
|
|
50
55
|
let relayCore
|
|
56
|
+
let deviceIdentity
|
|
51
57
|
let hostedKey = ''
|
|
58
|
+
let hostedPairCode = ''
|
|
59
|
+
let joinPairCode = ''
|
|
52
60
|
|
|
53
61
|
const sessions = {
|
|
54
62
|
async list() {
|
|
@@ -75,12 +83,29 @@ export function apply(ctx, rawConfig) {
|
|
|
75
83
|
signal,
|
|
76
84
|
)
|
|
77
85
|
},
|
|
78
|
-
prompt(sessionId,
|
|
86
|
+
async prompt(sessionId, input) {
|
|
87
|
+
const text = typeof input === 'string' ? input : String(input?.text ?? '')
|
|
88
|
+
const requested = typeof input === 'object' && Array.isArray(input?.attachments) ? input.attachments : []
|
|
89
|
+
const parts = []
|
|
90
|
+
if (text.trim()) parts.push({ type: 'text', text })
|
|
91
|
+
for (const item of requested) {
|
|
92
|
+
if (!item || typeof item !== 'object') continue
|
|
93
|
+
if (item.type === 'image' || item.type === 'file') {
|
|
94
|
+
if (item.attachment && typeof item.attachment === 'object') parts.push({ type: item.type, attachment: item.attachment })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (parts.length === 0) throw new Error('empty prompt')
|
|
98
|
+
// Admit through the attachment service so encoded images are normalized
|
|
99
|
+
// and durable references pass unchanged.
|
|
100
|
+
const attachments = typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined
|
|
101
|
+
const content = attachments && typeof attachments.admitPromptContent === 'function'
|
|
102
|
+
? await attachments.admitPromptContent(parts)
|
|
103
|
+
: parts
|
|
79
104
|
return ctx.sessionController.prompt({
|
|
80
105
|
requestId: randomUUID(),
|
|
81
106
|
sessionId,
|
|
82
107
|
mode: 'queue',
|
|
83
|
-
content
|
|
108
|
+
content,
|
|
84
109
|
clientTimeZone: timeZone(),
|
|
85
110
|
}, AbortSignal.timeout(30000))
|
|
86
111
|
},
|
|
@@ -92,10 +117,15 @@ export function apply(ctx, rawConfig) {
|
|
|
92
117
|
* remote read must not have side effects on the target.
|
|
93
118
|
*/
|
|
94
119
|
async config(sessionId) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
const out = { sessionId }
|
|
121
|
+
// Cordis throws when a service is read as a property without `inject`,
|
|
122
|
+
// so every optional service is reached through ctx.get inside a guard.
|
|
123
|
+
let liveSession
|
|
124
|
+
let agent
|
|
125
|
+
try { liveSession = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
|
|
126
|
+
try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
|
|
127
|
+
out.live = Boolean(liveSession)
|
|
128
|
+
out.running = Boolean(agent)
|
|
99
129
|
|
|
100
130
|
try {
|
|
101
131
|
const catalog = await ctx.sessionController.modelCatalog()
|
|
@@ -148,7 +178,9 @@ export function apply(ctx, rawConfig) {
|
|
|
148
178
|
|
|
149
179
|
const needsLive = typeof patch.permission === 'string' || typeof patch.plan === 'boolean'
|
|
150
180
|
if (needsLive) {
|
|
151
|
-
let agent
|
|
181
|
+
let agent
|
|
182
|
+
let session
|
|
183
|
+
try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
|
|
152
184
|
if (!agent) {
|
|
153
185
|
try {
|
|
154
186
|
const resolved = await ctx.sessionController.resolveAgent(sessionId)
|
|
@@ -157,7 +189,7 @@ export function apply(ctx, rawConfig) {
|
|
|
157
189
|
failures.push(`resume: ${error?.message || error}`)
|
|
158
190
|
}
|
|
159
191
|
}
|
|
160
|
-
|
|
192
|
+
try { session = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
|
|
161
193
|
|
|
162
194
|
if (typeof patch.permission === 'string') {
|
|
163
195
|
try {
|
|
@@ -187,16 +219,30 @@ export function apply(ctx, rawConfig) {
|
|
|
187
219
|
},
|
|
188
220
|
}
|
|
189
221
|
|
|
222
|
+
const uploads = createUploadManager({
|
|
223
|
+
log,
|
|
224
|
+
getAttachments: () => (typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined),
|
|
225
|
+
})
|
|
226
|
+
|
|
190
227
|
fabric = createFabric({
|
|
191
228
|
log,
|
|
192
229
|
config,
|
|
193
230
|
stateFile: peersFile,
|
|
231
|
+
store: createCredentialStore(ctx, peersFile, log),
|
|
232
|
+
uploads,
|
|
233
|
+
identity: () => deviceIdentity,
|
|
234
|
+
limits: { rateMax: config.rateMax, rateWindowMs: config.rateWindowMs },
|
|
194
235
|
self: () => ({
|
|
195
236
|
deviceId: state?.deviceId ?? '',
|
|
196
237
|
deviceName: config.deviceName,
|
|
197
238
|
userId: identity.userId,
|
|
198
239
|
port: info.port,
|
|
199
240
|
proto: info.proto,
|
|
241
|
+
relayUrl: relayLink?.url() ?? '',
|
|
242
|
+
pairCode: hostedPairCode || joinPairCode || config.pairCode || '',
|
|
243
|
+
publicKey: deviceIdentity?.ed25519?.publicKeyPem ?? '',
|
|
244
|
+
fingerprint: deviceIdentity?.fingerprint ?? '',
|
|
245
|
+
agreementKey: deviceIdentity?.x25519?.publicKeyPem ?? '',
|
|
200
246
|
}),
|
|
201
247
|
sessions,
|
|
202
248
|
})
|
|
@@ -204,6 +250,11 @@ export function apply(ctx, rawConfig) {
|
|
|
204
250
|
const ready = loadState(stateFile, config, log)
|
|
205
251
|
.then(async (loaded) => {
|
|
206
252
|
state = loaded
|
|
253
|
+
deviceIdentity = await loadOrCreateIdentity(
|
|
254
|
+
identityFile,
|
|
255
|
+
log,
|
|
256
|
+
createCredentialStore(ctx, identityFile, log, 'remote-control/identity'),
|
|
257
|
+
)
|
|
207
258
|
await refreshIdentity()
|
|
208
259
|
await start()
|
|
209
260
|
try {
|
|
@@ -221,11 +272,12 @@ export function apply(ctx, rawConfig) {
|
|
|
221
272
|
const persisted = await loadRelayConfig()
|
|
222
273
|
if (persisted?.hosted && persisted.key) {
|
|
223
274
|
try {
|
|
224
|
-
await startHostedRelay(persisted.key)
|
|
275
|
+
await startHostedRelay(persisted.key, persisted.pairCode)
|
|
225
276
|
} catch (error) {
|
|
226
277
|
log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
|
|
227
278
|
}
|
|
228
279
|
} else if (persisted) {
|
|
280
|
+
joinPairCode = typeof persisted.pairCode === 'string' ? persisted.pairCode : ''
|
|
229
281
|
try {
|
|
230
282
|
await startRelay(persisted.url, persisted.key)
|
|
231
283
|
} catch (error) {
|
|
@@ -305,10 +357,11 @@ export function apply(ctx, rawConfig) {
|
|
|
305
357
|
}
|
|
306
358
|
|
|
307
359
|
/** Serve the relay from this Host and point our own link at it. */
|
|
308
|
-
async function startHostedRelay(key) {
|
|
360
|
+
async function startHostedRelay(key, code) {
|
|
309
361
|
stopHostedRelay()
|
|
310
362
|
relayCore = createRelay({ log })
|
|
311
363
|
hostedKey = key
|
|
364
|
+
hostedPairCode = code || generatePairCode()
|
|
312
365
|
try {
|
|
313
366
|
await startRelay(`http://127.0.0.1:${info.port}`, key)
|
|
314
367
|
} catch (error) {
|
|
@@ -323,6 +376,7 @@ export function apply(ctx, rawConfig) {
|
|
|
323
376
|
relayCore = undefined
|
|
324
377
|
}
|
|
325
378
|
hostedKey = ''
|
|
379
|
+
hostedPairCode = ''
|
|
326
380
|
}
|
|
327
381
|
|
|
328
382
|
function shareUrls() {
|
|
@@ -335,6 +389,7 @@ export function apply(ctx, rawConfig) {
|
|
|
335
389
|
try { controller.abort() } catch { /* ignore */ }
|
|
336
390
|
}
|
|
337
391
|
streams.clear()
|
|
392
|
+
try { uploads?.close() } catch { /* ignore */ }
|
|
338
393
|
try { relayLink?.stop() } catch { /* ignore */ }
|
|
339
394
|
try { relayCore?.close() } catch { /* ignore */ }
|
|
340
395
|
try { fabric?.stop() } catch { /* ignore */ }
|
|
@@ -380,11 +435,13 @@ export function apply(ctx, rawConfig) {
|
|
|
380
435
|
peers: relayLink?.peers().length ?? 0,
|
|
381
436
|
hosted: Boolean(relayCore),
|
|
382
437
|
urls,
|
|
383
|
-
invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
|
|
438
|
+
invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}#${hostedPairCode}` : '',
|
|
439
|
+
pairCode: hostedPairCode || joinPairCode || '',
|
|
384
440
|
}
|
|
385
441
|
return json(res, 200, snapshot)
|
|
386
442
|
}
|
|
387
443
|
if (action === '/relay/host' && req.method === 'POST') {
|
|
444
|
+
await ready
|
|
388
445
|
const body = await readJson(req)
|
|
389
446
|
if (body?.enabled === false) {
|
|
390
447
|
stopRelay()
|
|
@@ -393,40 +450,66 @@ export function apply(ctx, rawConfig) {
|
|
|
393
450
|
return json(res, 200, { ok: true })
|
|
394
451
|
}
|
|
395
452
|
let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
|
|
453
|
+
const persisted = await loadRelayConfig()
|
|
396
454
|
if (!key) {
|
|
397
|
-
const persisted = await loadRelayConfig()
|
|
398
455
|
key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
|
|
399
456
|
}
|
|
457
|
+
const code = persisted?.pairCode && /^\d{4,8}$/.test(persisted.pairCode) ? persisted.pairCode : ''
|
|
400
458
|
try {
|
|
401
|
-
await startHostedRelay(key)
|
|
459
|
+
await startHostedRelay(key, code)
|
|
402
460
|
} catch (error) {
|
|
403
461
|
return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
|
|
404
462
|
}
|
|
405
463
|
const urls = shareUrls()
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
464
|
+
let reachability = null
|
|
465
|
+
try {
|
|
466
|
+
reachability = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
|
|
467
|
+
} catch { /* diagnostics only */ }
|
|
468
|
+
const inviteBase = reachability?.ok && reachability.url ? reachability.url : (urls[0] ?? '')
|
|
469
|
+
const allUrls = reachability?.ok && reachability.url ? [reachability.url, ...urls] : urls
|
|
470
|
+
await saveRelayConfig({ hosted: true, url: inviteBase, key, pairCode: hostedPairCode })
|
|
471
|
+
log.info(`[remote-control] hosting a relay at ${inviteBase || '(no address)'}`)
|
|
472
|
+
return json(res, 200, {
|
|
473
|
+
ok: true,
|
|
474
|
+
urls: allUrls,
|
|
475
|
+
pairCode: hostedPairCode,
|
|
476
|
+
reachability,
|
|
477
|
+
invite: `${inviteBase} ${key}#${hostedPairCode}`,
|
|
478
|
+
})
|
|
409
479
|
}
|
|
410
480
|
if (action === '/relay' && req.method === 'POST') {
|
|
481
|
+
await ready
|
|
411
482
|
const body = await readJson(req)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
let
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
483
|
+
let url = String(body?.url || '').trim()
|
|
484
|
+
let key = String(body?.key || '')
|
|
485
|
+
let code = String(body?.pairCode || '')
|
|
486
|
+
// Invite form "<url> <key>#<code>": accept it whole or already split.
|
|
487
|
+
const urlHash = url.indexOf('#')
|
|
488
|
+
if (urlHash >= 0) { code = code || url.slice(urlHash + 1); url = url.slice(0, urlHash) }
|
|
489
|
+
const keyHash = key.indexOf('#')
|
|
490
|
+
if (keyHash >= 0) { code = code || key.slice(keyHash + 1); key = key.slice(0, keyHash) }
|
|
491
|
+
const verdict = checkRelayUrl(url, { allowInsecure: config.allowInsecureRelay })
|
|
492
|
+
if (!verdict.ok) return json(res, 400, { error: verdict.error, message: verdict.message })
|
|
419
493
|
if (key.length < 16) {
|
|
420
494
|
return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
|
|
421
495
|
}
|
|
496
|
+
joinPairCode = /^\d{4,8}$/.test(code) ? code : ''
|
|
422
497
|
try {
|
|
423
498
|
await startRelay(url, key)
|
|
424
499
|
} catch (error) {
|
|
425
500
|
return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
|
|
426
501
|
}
|
|
427
502
|
stopHostedRelay()
|
|
428
|
-
await saveRelayConfig({ hosted: false, url, key })
|
|
429
|
-
return json(res, 200, { ok: true })
|
|
503
|
+
await saveRelayConfig({ hosted: false, url, key, pairCode: joinPairCode })
|
|
504
|
+
return json(res, 200, { ok: true, pairCode: joinPairCode })
|
|
505
|
+
}
|
|
506
|
+
if (action === '/relay/reachability' && req.method === 'GET') {
|
|
507
|
+
await ready
|
|
508
|
+
if (!relayCore) {
|
|
509
|
+
return json(res, 409, { error: 'not-hosting', message: 'Create a relay on this device first.' })
|
|
510
|
+
}
|
|
511
|
+
const result = await ensureReachable({ port: info.port, allowInsecure: config.allowInsecureRelay })
|
|
512
|
+
return json(res, 200, result)
|
|
430
513
|
}
|
|
431
514
|
if (action === '/relay/clear' && req.method === 'POST') {
|
|
432
515
|
stopRelay()
|
|
@@ -434,6 +517,33 @@ export function apply(ctx, rawConfig) {
|
|
|
434
517
|
await clearRelayConfig()
|
|
435
518
|
return json(res, 200, { ok: true })
|
|
436
519
|
}
|
|
520
|
+
if (action === '/qr' && req.method === 'GET') {
|
|
521
|
+
const text = String(url.searchParams.get('text') || '')
|
|
522
|
+
if (!text || text.length > 106) return json(res, 400, { error: 'bad-text' })
|
|
523
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-store' })
|
|
524
|
+
return res.end(qrSvg(text))
|
|
525
|
+
}
|
|
526
|
+
if (action.startsWith('/upload/') && req.method === 'POST') {
|
|
527
|
+
const name = action.slice('/upload/'.length)
|
|
528
|
+
const handler = uploads?.[name]
|
|
529
|
+
if (typeof handler !== 'function') return json(res, 404, { error: 'not-found' })
|
|
530
|
+
let body
|
|
531
|
+
try {
|
|
532
|
+
body = await readJson(req, 8 * 1024 * 1024)
|
|
533
|
+
} catch {
|
|
534
|
+
return json(res, 400, { error: 'bad-body' })
|
|
535
|
+
}
|
|
536
|
+
const result = await handler(body)
|
|
537
|
+
return json(res, result.status ?? 200, result)
|
|
538
|
+
}
|
|
539
|
+
if (action === '/revoke' && req.method === 'POST') {
|
|
540
|
+
const body = await readJson(req)
|
|
541
|
+
return json(res, 200, fabric.revoke(String(body?.deviceId || '')))
|
|
542
|
+
}
|
|
543
|
+
if (action === '/scopes' && req.method === 'POST') {
|
|
544
|
+
const body = await readJson(req)
|
|
545
|
+
return json(res, 200, fabric.setScopes(String(body?.deviceId || ''), body?.scopes))
|
|
546
|
+
}
|
|
437
547
|
if (action === '/approve' && req.method === 'POST') {
|
|
438
548
|
const body = await readJson(req)
|
|
439
549
|
return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
|
|
@@ -815,6 +925,75 @@ function escapeHtml(value) {
|
|
|
815
925
|
}[character]))
|
|
816
926
|
}
|
|
817
927
|
|
|
928
|
+
/* ------------------------------------------------------------------ */
|
|
929
|
+
/* approval secret store */
|
|
930
|
+
/* ------------------------------------------------------------------ */
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Peer grants live in the Host credential store as a `grant` record, the
|
|
934
|
+
* sanctioned secret store, with the legacy 0600 JSON file as a fallback: a
|
|
935
|
+
* credential failure may never stop the plugin from starting, and a first run
|
|
936
|
+
* migrates the file content into the record.
|
|
937
|
+
*/
|
|
938
|
+
function createCredentialStore(ctx, legacyFile, log, key = 'remote-control/peers') {
|
|
939
|
+
const file = {
|
|
940
|
+
async load() {
|
|
941
|
+
try {
|
|
942
|
+
return JSON.parse(await readFile(legacyFile, 'utf8'))
|
|
943
|
+
} catch (error) {
|
|
944
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${legacyFile}: ${error?.message || error}`)
|
|
945
|
+
return null
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
async save(value) {
|
|
949
|
+
try {
|
|
950
|
+
await mkdir(path.dirname(legacyFile), { recursive: true })
|
|
951
|
+
await writeFile(legacyFile, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
952
|
+
} catch (error) {
|
|
953
|
+
log.warn(`[remote-control] could not persist ${legacyFile}: ${error?.message || error}`)
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let credentials
|
|
959
|
+
try {
|
|
960
|
+
credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
|
|
961
|
+
} catch {
|
|
962
|
+
credentials = undefined
|
|
963
|
+
}
|
|
964
|
+
if (!credentials || typeof credentials.readRecord !== 'function' || typeof credentials.modifyRecord !== 'function') {
|
|
965
|
+
return file
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
async load() {
|
|
970
|
+
try {
|
|
971
|
+
const record = await credentials.readRecord(key)
|
|
972
|
+
if (record && record.kind === 'grant' && record.payload) return record.payload
|
|
973
|
+
} catch (error) {
|
|
974
|
+
log.warn(`[remote-control] credential record read failed: ${error?.message || error}`)
|
|
975
|
+
}
|
|
976
|
+
const legacy = await file.load()
|
|
977
|
+
if (legacy) {
|
|
978
|
+
try {
|
|
979
|
+
await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: legacy }))
|
|
980
|
+
log.info('[remote-control] migrated peer grants into the credential store')
|
|
981
|
+
} catch { /* the file copy remains authoritative */ }
|
|
982
|
+
}
|
|
983
|
+
return legacy
|
|
984
|
+
},
|
|
985
|
+
async save(value) {
|
|
986
|
+
try {
|
|
987
|
+
await credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: value }))
|
|
988
|
+
return
|
|
989
|
+
} catch (error) {
|
|
990
|
+
log.warn(`[remote-control] credential record write failed, using the file fallback: ${error?.message || error}`)
|
|
991
|
+
}
|
|
992
|
+
await file.save(value)
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
818
997
|
/* ------------------------------------------------------------------ */
|
|
819
998
|
/* configuration */
|
|
820
999
|
/* ------------------------------------------------------------------ */
|
|
@@ -832,6 +1011,14 @@ function normalizeConfig(raw = {}) {
|
|
|
832
1011
|
discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
|
|
833
1012
|
relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
|
|
834
1013
|
relayKey: typeof raw?.relayKey === 'string' ? raw.relayKey : '',
|
|
1014
|
+
allowInsecureRelay: raw?.allowInsecureRelay === true,
|
|
1015
|
+
pairCode: typeof raw?.pairCode === 'string' ? raw.pairCode.trim() : '',
|
|
1016
|
+
rateMax: Number.isInteger(raw?.rateMax) ? raw.rateMax : 120,
|
|
1017
|
+
rateWindowMs: Number.isInteger(raw?.rateWindowMs) ? raw.rateWindowMs : 10_000,
|
|
1018
|
+
punchPort: Number.isInteger(raw?.punchPort) ? raw.punchPort : 0,
|
|
1019
|
+
stunHost: raw?.stunHost === false ? false : (typeof raw?.stunHost === 'string' ? raw.stunHost : 'stun.l.google.com'),
|
|
1020
|
+
stunPort: Number.isInteger(raw?.stunPort) ? raw.stunPort : 19302,
|
|
1021
|
+
stunTimeoutMs: Number.isInteger(raw?.stunTimeoutMs) ? raw.stunTimeoutMs : 1500,
|
|
835
1022
|
deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
|
|
836
1023
|
tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
|
|
837
1024
|
}
|
|
@@ -848,15 +1035,8 @@ function normalizeConfig(raw = {}) {
|
|
|
848
1035
|
}
|
|
849
1036
|
}
|
|
850
1037
|
if (config.relayUrl) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
parsed = new URL(config.relayUrl)
|
|
854
|
-
} catch {
|
|
855
|
-
throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
|
|
856
|
-
}
|
|
857
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
858
|
-
throw new Error('remote-control: `relayUrl` must use http or https')
|
|
859
|
-
}
|
|
1038
|
+
const verdict = checkRelayUrl(config.relayUrl, { allowInsecure: config.allowInsecureRelay })
|
|
1039
|
+
if (!verdict.ok) throw new Error(`remote-control: ${verdict.message}`)
|
|
860
1040
|
if (!config.relayKey || config.relayKey.length < 16) {
|
|
861
1041
|
throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
|
|
862
1042
|
}
|
|
@@ -912,6 +1092,10 @@ function generateCode() {
|
|
|
912
1092
|
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
|
913
1093
|
}
|
|
914
1094
|
|
|
1095
|
+
function generatePairCode() {
|
|
1096
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
|
1097
|
+
}
|
|
1098
|
+
|
|
915
1099
|
function timeZone() {
|
|
916
1100
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
|
|
917
1101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-control-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remote control for DSH: a Devices panel that discovers other DSH desktops signed into the same account, pairs with them once, and drives their sessions.",
|
|
6
6
|
"exports": {
|