dsh-remote-control-plugin 0.3.6 → 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/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, text) {
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: [{ type: 'text', text }],
108
+ content,
84
109
  clientTimeZone: timeZone(),
85
110
  }, AbortSignal.timeout(30000))
86
111
  },
@@ -194,16 +219,30 @@ export function apply(ctx, rawConfig) {
194
219
  },
195
220
  }
196
221
 
222
+ const uploads = createUploadManager({
223
+ log,
224
+ getAttachments: () => (typeof ctx.get === 'function' ? safely(() => ctx.get('attachments')) : undefined),
225
+ })
226
+
197
227
  fabric = createFabric({
198
228
  log,
199
229
  config,
200
230
  stateFile: peersFile,
231
+ store: createCredentialStore(ctx, peersFile, log),
232
+ uploads,
233
+ identity: () => deviceIdentity,
234
+ limits: { rateMax: config.rateMax, rateWindowMs: config.rateWindowMs },
201
235
  self: () => ({
202
236
  deviceId: state?.deviceId ?? '',
203
237
  deviceName: config.deviceName,
204
238
  userId: identity.userId,
205
239
  port: info.port,
206
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 ?? '',
207
246
  }),
208
247
  sessions,
209
248
  })
@@ -211,6 +250,11 @@ export function apply(ctx, rawConfig) {
211
250
  const ready = loadState(stateFile, config, log)
212
251
  .then(async (loaded) => {
213
252
  state = loaded
253
+ deviceIdentity = await loadOrCreateIdentity(
254
+ identityFile,
255
+ log,
256
+ createCredentialStore(ctx, identityFile, log, 'remote-control/identity'),
257
+ )
214
258
  await refreshIdentity()
215
259
  await start()
216
260
  try {
@@ -228,11 +272,12 @@ export function apply(ctx, rawConfig) {
228
272
  const persisted = await loadRelayConfig()
229
273
  if (persisted?.hosted && persisted.key) {
230
274
  try {
231
- await startHostedRelay(persisted.key)
275
+ await startHostedRelay(persisted.key, persisted.pairCode)
232
276
  } catch (error) {
233
277
  log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
234
278
  }
235
279
  } else if (persisted) {
280
+ joinPairCode = typeof persisted.pairCode === 'string' ? persisted.pairCode : ''
236
281
  try {
237
282
  await startRelay(persisted.url, persisted.key)
238
283
  } catch (error) {
@@ -312,10 +357,11 @@ export function apply(ctx, rawConfig) {
312
357
  }
313
358
 
314
359
  /** Serve the relay from this Host and point our own link at it. */
315
- async function startHostedRelay(key) {
360
+ async function startHostedRelay(key, code) {
316
361
  stopHostedRelay()
317
362
  relayCore = createRelay({ log })
318
363
  hostedKey = key
364
+ hostedPairCode = code || generatePairCode()
319
365
  try {
320
366
  await startRelay(`http://127.0.0.1:${info.port}`, key)
321
367
  } catch (error) {
@@ -330,6 +376,7 @@ export function apply(ctx, rawConfig) {
330
376
  relayCore = undefined
331
377
  }
332
378
  hostedKey = ''
379
+ hostedPairCode = ''
333
380
  }
334
381
 
335
382
  function shareUrls() {
@@ -342,6 +389,7 @@ export function apply(ctx, rawConfig) {
342
389
  try { controller.abort() } catch { /* ignore */ }
343
390
  }
344
391
  streams.clear()
392
+ try { uploads?.close() } catch { /* ignore */ }
345
393
  try { relayLink?.stop() } catch { /* ignore */ }
346
394
  try { relayCore?.close() } catch { /* ignore */ }
347
395
  try { fabric?.stop() } catch { /* ignore */ }
@@ -387,11 +435,13 @@ export function apply(ctx, rawConfig) {
387
435
  peers: relayLink?.peers().length ?? 0,
388
436
  hosted: Boolean(relayCore),
389
437
  urls,
390
- invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
438
+ invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}#${hostedPairCode}` : '',
439
+ pairCode: hostedPairCode || joinPairCode || '',
391
440
  }
392
441
  return json(res, 200, snapshot)
393
442
  }
394
443
  if (action === '/relay/host' && req.method === 'POST') {
444
+ await ready
395
445
  const body = await readJson(req)
396
446
  if (body?.enabled === false) {
397
447
  stopRelay()
@@ -400,40 +450,66 @@ export function apply(ctx, rawConfig) {
400
450
  return json(res, 200, { ok: true })
401
451
  }
402
452
  let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
453
+ const persisted = await loadRelayConfig()
403
454
  if (!key) {
404
- const persisted = await loadRelayConfig()
405
455
  key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
406
456
  }
457
+ const code = persisted?.pairCode && /^\d{4,8}$/.test(persisted.pairCode) ? persisted.pairCode : ''
407
458
  try {
408
- await startHostedRelay(key)
459
+ await startHostedRelay(key, code)
409
460
  } catch (error) {
410
461
  return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
411
462
  }
412
463
  const urls = shareUrls()
413
- await saveRelayConfig({ hosted: true, url: urls[0] ?? '', key })
414
- log.info(`[remote-control] hosting a relay at ${urls[0] ?? '(no address)'}`)
415
- return json(res, 200, { ok: true, urls, invite: `${urls[0] ?? ''} ${key}` })
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
+ })
416
479
  }
417
480
  if (action === '/relay' && req.method === 'POST') {
481
+ await ready
418
482
  const body = await readJson(req)
419
- const url = String(body?.url || '').trim()
420
- const key = String(body?.key || '')
421
- let parsed
422
- try { parsed = new URL(url) } catch { return json(res, 400, { error: 'bad-url', message: 'Relay URL must be absolute.' }) }
423
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
424
- return json(res, 400, { error: 'bad-url', message: 'Relay URL must use http or https.' })
425
- }
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 })
426
493
  if (key.length < 16) {
427
494
  return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
428
495
  }
496
+ joinPairCode = /^\d{4,8}$/.test(code) ? code : ''
429
497
  try {
430
498
  await startRelay(url, key)
431
499
  } catch (error) {
432
500
  return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
433
501
  }
434
502
  stopHostedRelay()
435
- await saveRelayConfig({ hosted: false, url, key })
436
- 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)
437
513
  }
438
514
  if (action === '/relay/clear' && req.method === 'POST') {
439
515
  stopRelay()
@@ -441,6 +517,33 @@ export function apply(ctx, rawConfig) {
441
517
  await clearRelayConfig()
442
518
  return json(res, 200, { ok: true })
443
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
+ }
444
547
  if (action === '/approve' && req.method === 'POST') {
445
548
  const body = await readJson(req)
446
549
  return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
@@ -822,6 +925,75 @@ function escapeHtml(value) {
822
925
  }[character]))
823
926
  }
824
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
+
825
997
  /* ------------------------------------------------------------------ */
826
998
  /* configuration */
827
999
  /* ------------------------------------------------------------------ */
@@ -839,6 +1011,14 @@ function normalizeConfig(raw = {}) {
839
1011
  discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
840
1012
  relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
841
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,
842
1022
  deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
843
1023
  tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
844
1024
  }
@@ -855,15 +1035,8 @@ function normalizeConfig(raw = {}) {
855
1035
  }
856
1036
  }
857
1037
  if (config.relayUrl) {
858
- let parsed
859
- try {
860
- parsed = new URL(config.relayUrl)
861
- } catch {
862
- throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
863
- }
864
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
865
- throw new Error('remote-control: `relayUrl` must use http or https')
866
- }
1038
+ const verdict = checkRelayUrl(config.relayUrl, { allowInsecure: config.allowInsecureRelay })
1039
+ if (!verdict.ok) throw new Error(`remote-control: ${verdict.message}`)
867
1040
  if (!config.relayKey || config.relayKey.length < 16) {
868
1041
  throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
869
1042
  }
@@ -919,6 +1092,10 @@ function generateCode() {
919
1092
  return String(randomInt(0, 1_000_000)).padStart(6, '0')
920
1093
  }
921
1094
 
1095
+ function generatePairCode() {
1096
+ return String(randomInt(0, 1_000_000)).padStart(6, '0')
1097
+ }
1098
+
922
1099
  function timeZone() {
923
1100
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
924
1101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-control-plugin",
3
- "version": "0.3.6",
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": {