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/INSTALL.md CHANGED
@@ -4,12 +4,12 @@ Published as `dsh-remote-control-plugin`. Self-contained: no dependencies, no bu
4
4
 
5
5
  ## Install
6
6
 
7
- Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.6`, keep the official npm
7
+ Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.4.0`, keep the official npm
8
8
  registry selected, then **Install** and **Enable now**. Restart DSH.
9
9
 
10
10
  From a chat (Standard or Creator preset — PTC disables `tool-plugin-manager`):
11
11
 
12
- > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.6` with plugin_manager.
12
+ > Install the DSH plugin bundle `dsh-remote-control-plugin@0.4.0` with plugin_manager.
13
13
 
14
14
  ## Connect two machines
15
15
 
@@ -27,7 +27,8 @@ Same-LAN machines need no relay. No reachable machine? Run
27
27
  - Approvals live in `$DSH_HOME/remote-control-peers.json`; delete it to revoke.
28
28
  - Relay and control API share one port (default `57330`).
29
29
  - Peer traffic is signed but not encrypted; use a private network or TLS in front.
30
- - 0.3.6 fixes: ordered transcript chunks, hosted relay persistence, stale
30
+ - 0.4.0 fixes: ordered transcript chunks, hosted relay persistence, stale
31
31
  relay peer pruning, relay fallback when a direct LAN route is blocked, a client
32
32
  module id that matches the package name, and a Devices config row for the
33
- remote session's model, permission preset, and plan mode.
33
+ remote session's model, permission preset, and plan mode. It also adds peer
34
+ file uploads (chunked, resumable, stored through the Host attachment service).
package/client.js CHANGED
@@ -126,6 +126,8 @@ window.__ModuleLoader__.load({
126
126
  const [live, setLive] = useState('')
127
127
  const [running, setRunning] = useState(false)
128
128
  const [draft, setDraft] = useState('')
129
+ const [pendingFiles, setPendingFiles] = useState([])
130
+ const [uploading, setUploading] = useState(null)
129
131
  const [error, setError] = useState(null)
130
132
  const [streamState, setStreamState] = useState('connecting')
131
133
  const [attempt, setAttempt] = useState(0)
@@ -197,18 +199,54 @@ window.__ModuleLoader__.load({
197
199
  if (node) node.scrollTop = node.scrollHeight
198
200
  }, [entries, live])
199
201
 
202
+ const addFiles = useCallback((list) => {
203
+ const files = Array.from(list ?? []).filter((file) => file && typeof file.size === 'number')
204
+ if (files.length > 0) setPendingFiles((current) => [...current, ...files])
205
+ }, [])
206
+
207
+ const dropFiles = useCallback((event) => {
208
+ event.preventDefault()
209
+ addFiles(event.dataTransfer?.files)
210
+ }, [addFiles])
211
+
200
212
  const send = useCallback(async () => {
201
213
  const text = draft.trim()
202
- if (!text || !peerId || !sessionId) return
214
+ const pending = pendingFiles
215
+ if ((!text && pending.length === 0) || !peerId || !sessionId) return
203
216
  setDraft('')
204
217
  setError(null)
205
218
  try {
206
- await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/prompt`, { sessionId, text })
219
+ const sent = []
220
+ for (const file of pending) {
221
+ setUploading({ name: file.name, progress: 0 })
222
+ const init = await api('/remote-control/api/upload/init', {
223
+ sessionId,
224
+ name: file.name || 'file',
225
+ size: file.size,
226
+ mediaType: file.type || 'application/octet-stream',
227
+ })
228
+ const uploadId = init?.uploadId
229
+ if (!uploadId) throw new Error('The device refused the upload.')
230
+ const chunkSize = 512 * 1024
231
+ for (let offset = 0, index = 0; offset < file.size; offset += chunkSize, index += 1) {
232
+ const bytes = new Uint8Array(await file.slice(offset, offset + chunkSize).arrayBuffer())
233
+ let binary = ''
234
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i])
235
+ await api('/remote-control/api/upload/chunk', { uploadId, index, data: btoa(binary) })
236
+ setUploading({ name: file.name, progress: Math.min(1, (offset + chunkSize) / Math.max(1, file.size)) })
237
+ }
238
+ const committed = await api('/remote-control/api/upload/commit', { uploadId })
239
+ if (committed?.attachment) sent.push(committed.attachment)
240
+ }
241
+ setUploading(null)
242
+ setPendingFiles([])
243
+ await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/prompt`, { sessionId, text, attachments: sent })
207
244
  } catch (failure) {
245
+ setUploading(null)
208
246
  setDraft(text)
209
247
  setError(failure.message)
210
248
  }
211
- }, [draft, peerId, sessionId])
249
+ }, [draft, pendingFiles, peerId, sessionId])
212
250
 
213
251
  const stop = useCallback(async () => {
214
252
  try {
@@ -330,7 +368,29 @@ window.__ModuleLoader__.load({
330
368
  live ? h('div', { style: { ...S.bubble, ...S.assistantBubble, opacity: 0.85 } }, live) : null,
331
369
  ),
332
370
  configBar,
333
- h('div', { style: S.composer },
371
+ h('div', { style: S.composer, onDragOver: (event) => event.preventDefault(), onDrop: dropFiles },
372
+ pendingFiles.length > 0 || uploading
373
+ ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 } },
374
+ pendingFiles.map((file, index) => h('span', {
375
+ key: `${file.name}-${index}`,
376
+ style: {
377
+ display: 'inline-flex', gap: 6, alignItems: 'center', padding: '2px 8px',
378
+ borderRadius: 999, background: 'rgba(127,127,127,0.18)', fontSize: 12,
379
+ },
380
+ },
381
+ `${file.name} · ${Math.max(1, Math.round(file.size / 1024))} KB`,
382
+ h('button', {
383
+ type: 'button',
384
+ title: 'Remove',
385
+ style: { border: 'none', background: 'transparent', cursor: 'pointer', color: 'inherit', padding: 0 },
386
+ onClick: () => setPendingFiles((current) => current.filter((_, position) => position !== index)),
387
+ }, '✕'),
388
+ )),
389
+ uploading
390
+ ? h('span', { style: { fontSize: 12, opacity: 0.8 } }, `Uploading ${uploading.name} ${Math.round(uploading.progress * 100)}%`)
391
+ : null,
392
+ )
393
+ : null,
334
394
  h('textarea', {
335
395
  style: S.input,
336
396
  rows: 1,
@@ -341,8 +401,22 @@ window.__ModuleLoader__.load({
341
401
  if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() }
342
402
  },
343
403
  }),
404
+ h('label', { style: { ...S.button, cursor: 'pointer' }, title: 'Attach files' },
405
+ 'Attach',
406
+ h('input', {
407
+ type: 'file',
408
+ multiple: true,
409
+ style: { display: 'none' },
410
+ onChange: (event) => { addFiles(event.target.files); event.target.value = '' },
411
+ }),
412
+ ),
344
413
  running ? h('button', { type: 'button', style: { ...S.button, ...S.danger }, onClick: stop }, 'Stop') : null,
345
- h('button', { type: 'button', style: { ...S.button, ...S.primary }, onClick: send, disabled: !draft.trim() }, 'Send'),
414
+ h('button', {
415
+ type: 'button',
416
+ style: { ...S.button, ...S.primary },
417
+ onClick: send,
418
+ disabled: uploading !== null || (!draft.trim() && pendingFiles.length === 0),
419
+ }, uploading ? 'Uploading…' : 'Send'),
346
420
  ),
347
421
  )
348
422
  }
@@ -401,6 +475,7 @@ window.__ModuleLoader__.load({
401
475
  const [relayOpen, setRelayOpen] = useState(false)
402
476
  const [relayUrl, setRelayUrl] = useState('')
403
477
  const [relayKey, setRelayKey] = useState('')
478
+ const [reachability, setReachability] = useState(null)
404
479
 
405
480
  const refresh = useCallback(async () => {
406
481
  try {
@@ -477,7 +552,8 @@ window.__ModuleLoader__.load({
477
552
  setBusy('relay')
478
553
  setError(null)
479
554
  try {
480
- await api('/remote-control/api/relay/host', { enabled: true })
555
+ const value = await api('/remote-control/api/relay/host', { enabled: true })
556
+ if (value?.reachability) setReachability(value.reachability)
481
557
  } catch (failure) {
482
558
  setError(failure.message)
483
559
  }
@@ -488,10 +564,23 @@ window.__ModuleLoader__.load({
488
564
  const stopHosting = useCallback(async () => {
489
565
  setBusy('relay')
490
566
  try { await api('/remote-control/api/relay/host', { enabled: false }) } catch (failure) { setError(failure.message) }
567
+ setReachability(null)
491
568
  setBusy(null)
492
569
  refresh()
493
570
  }, [refresh])
494
571
 
572
+ const checkReachability = useCallback(async () => {
573
+ setBusy('reach')
574
+ setError(null)
575
+ try {
576
+ const value = await api('/remote-control/api/relay/reachability')
577
+ setReachability(value)
578
+ } catch (failure) {
579
+ setError(failure.message)
580
+ }
581
+ setBusy(null)
582
+ }, [])
583
+
495
584
  const clearRelay = useCallback(async () => {
496
585
  setBusy('relay')
497
586
  try { await api('/remote-control/api/relay/clear', {}) } catch (failure) { setError(failure.message) }
@@ -526,6 +615,13 @@ window.__ModuleLoader__.load({
526
615
  ? h('div', null,
527
616
  h('div', { style: { ...S.meta, marginBottom: '6px' } },
528
617
  'This device is hosting the relay. Paste this invite into the other device’s Relay panel.'),
618
+ relay.invite
619
+ ? h('img', {
620
+ alt: 'Invite QR code',
621
+ src: `/remote-control/api/qr?text=${encodeURIComponent(relay.invite)}`,
622
+ style: { width: '184px', height: '184px', marginBottom: '10px', background: '#fff', borderRadius: '8px', padding: '6px' },
623
+ })
624
+ : null,
529
625
  h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
530
626
  h('input', {
531
627
  style: { ...S.field, flex: 1, minWidth: '260px' },
@@ -542,6 +638,20 @@ window.__ModuleLoader__.load({
542
638
  ),
543
639
  h('div', { style: { ...S.meta, marginTop: '6px' } },
544
640
  `Reachable at ${(relay.urls || []).join(', ') || 'this machine'} — forward that port (or set publicUrl) so other networks can reach it.`),
641
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '8px' } },
642
+ h('button', {
643
+ type: 'button',
644
+ style: S.button,
645
+ disabled: busy === 'reach',
646
+ onClick: checkReachability,
647
+ }, 'Check reachability'),
648
+ reachability
649
+ ? h('span', { style: { ...S.meta, flex: 1 } },
650
+ reachability.ok
651
+ ? `Reachable at ${reachability.url}${reachability.upnp?.mapped ? ' (UPnP mapping)' : ''}`
652
+ : `Not reachable${reachability.upnp?.available === false ? ' — no UPnP gateway on this network' : reachability.hint ? ` — ${reachability.hint}` : ''}`)
653
+ : null,
654
+ ),
545
655
  )
546
656
  : h('div', null,
547
657
  h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
@@ -580,6 +690,13 @@ window.__ModuleLoader__.load({
580
690
  h('div', { style: { marginBottom: '8px' } },
581
691
  h('strong', null, request.deviceName),
582
692
  ` wants to control this computer (account ${shortAccount(request.userId)}).`),
693
+ h('div', { style: { ...S.meta, marginBottom: '8px' } },
694
+ [
695
+ request.sas ? `SAS ${request.sas}` : '',
696
+ request.fingerprint ? `key ${String(request.fingerprint).slice(0, 12)}` : '',
697
+ request.relay ? `via ${request.relay}` : '',
698
+ request.relayChanged ? 'relay changed since the last approval' : '',
699
+ ].filter(Boolean).join(' · ')),
583
700
  h('div', { style: { display: 'flex', gap: '8px' } },
584
701
  h('button', {
585
702
  type: 'button', style: { ...S.button, ...S.primary }, disabled: busy === request.requestId,
@@ -663,6 +780,8 @@ window.__ModuleLoader__.load({
663
780
 
664
781
  return {
665
782
  inject: ['slots'],
783
+ // Exposed for the headless render test (scripts/client-render-test.mjs).
784
+ __components: { Icon, DevicesPage, Chat },
666
785
  apply(ctx) {
667
786
  ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
668
787
  name: 'sidebar.panellist', id: 'devices', order: 20, label: () => 'Devices',
package/host/crypto.js ADDED
@@ -0,0 +1,145 @@
1
+ import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Random-nonce AEAD for unreliable transports: a duplicated or reordered
5
+ * datagram is harmless because each packet carries its own nonce, and plaintext
6
+ * replay is stopped at the request layer by the signed timestamp + nonce.
7
+ */
8
+ export function createBoxCipher(key) {
9
+ const TAG = 16
10
+ return {
11
+ seal(plaintext) {
12
+ const nonce = randomBytes(12)
13
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: TAG })
14
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
15
+ return Buffer.concat([nonce, body, cipher.getAuthTag()]).toString('base64')
16
+ },
17
+ open(packet) {
18
+ const raw = Buffer.from(String(packet), 'base64')
19
+ if (raw.length < 12 + TAG) throw new Error('box: short packet')
20
+ const nonce = raw.subarray(0, 12)
21
+ const tag = raw.subarray(raw.length - TAG)
22
+ const body = raw.subarray(12, raw.length - TAG)
23
+ const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: TAG })
24
+ decipher.setAuthTag(tag)
25
+ return Buffer.concat([decipher.update(body), decipher.final()])
26
+ },
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Peer channel crypto (phase A2).
32
+ *
33
+ * Static-static X25519 agreement per device pair, HKDF-SHA256 to a 32-byte
34
+ * session key, then ChaCha20-Poly1305 with a per-direction counter nonce:
35
+ *
36
+ * nonce = 1 byte direction | 11 byte big-endian counter
37
+ *
38
+ * The receiver demands the exact next nonce, so a replayed, reordered or
39
+ * forked packet fails authentication instead of decrypting. AAD is empty
40
+ * because the nonce already binds direction and position; the ciphertext is
41
+ * authenticated over its full length.
42
+ *
43
+ * Known limitation: static keys give no forward secrecy. Replacing this with a
44
+ * Noise-IK-style ephemeral handshake is the next step; the wire format already
45
+ * carries a version tag so both can coexist.
46
+ */
47
+
48
+ export const CHANNEL_VERSION = 1
49
+
50
+ /**
51
+ * Ephemeral key agreement for forward secrecy.
52
+ *
53
+ * The session key mixes one ephemeral DH (fresh per pairing) with one static DH
54
+ * that authenticates the peer's long-term key. The ephemeral private key is
55
+ * dropped when the pairing context is disposed, so a later compromise of the
56
+ * long-term keys cannot reconstruct past session keys.
57
+ *
58
+ * This is IK-shaped but NOT a Noise implementation: no protocol name hash, no
59
+ * handshake transcript beyond the fingerprint salt. Do not describe it as Noise.
60
+ */
61
+ export function generateEphemeralKeypair() {
62
+ return generateKeyPairSync('x25519')
63
+ }
64
+
65
+ export function ephemeralPublicPem(pair) {
66
+ return pair.publicKey.export({ type: 'spki', format: 'pem' })
67
+ }
68
+
69
+ export function deriveEphemeralKey({
70
+ ephemeralPrivate,
71
+ peerEphemeralPublicPem,
72
+ staticPrivate,
73
+ peerStaticPublicPem,
74
+ myFingerprint,
75
+ peerFingerprint,
76
+ label = 'channel',
77
+ }) {
78
+ const ephemeralShared = diffieHellman({
79
+ privateKey: ephemeralPrivate,
80
+ publicKey: createPublicKey(peerEphemeralPublicPem),
81
+ })
82
+ const staticShared = diffieHellman({
83
+ privateKey: staticPrivate,
84
+ publicKey: createPublicKey(peerStaticPublicPem),
85
+ })
86
+ const salt = Buffer.from([myFingerprint, peerFingerprint].sort().join(':'), 'utf8')
87
+ const info = Buffer.from(`dsh-remote-control/${label}-fs/v${CHANNEL_VERSION}`, 'utf8')
88
+ return Buffer.from(hkdfSync('sha256', Buffer.concat([ephemeralShared, staticShared]), salt, info, 32))
89
+ }
90
+
91
+ export function deriveSessionKey({ privateKey, peerPublicKeyPem, myFingerprint, peerFingerprint, label = 'channel' }) {
92
+ const peerPublicKey = createPublicKey(peerPublicKeyPem)
93
+ const shared = diffieHellman({ privateKey, publicKey: peerPublicKey })
94
+ const salt = Buffer.from([myFingerprint, peerFingerprint].sort().join(':'), 'utf8')
95
+ const info = Buffer.from(`dsh-remote-control/${label}/v${CHANNEL_VERSION}`, 'utf8')
96
+ return Buffer.from(hkdfSync('sha256', shared, salt, info, 32))
97
+ }
98
+
99
+ function nonceFor(direction, counter) {
100
+ const nonce = Buffer.alloc(12)
101
+ nonce[0] = direction
102
+ nonce.writeBigUInt64BE(BigInt(counter), 4)
103
+ return nonce
104
+ }
105
+
106
+ /**
107
+ * One direction-aware AEAD pair for a peer session. `side` fixes which
108
+ * direction byte this endpoint sends with, so both peers agree without
109
+ * negotiating it on the wire.
110
+ */
111
+ export function createCipherPair(key, side) {
112
+ const sendDirection = side === 'a' ? 1 : 2
113
+ const receiveDirection = side === 'a' ? 2 : 1
114
+ let sendCounter = 0
115
+ let lastReceived = -1n
116
+
117
+ return {
118
+ seal(plaintext) {
119
+ const nonce = nonceFor(sendDirection, sendCounter)
120
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 })
121
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
122
+ sendCounter += 1
123
+ return Buffer.concat([nonce, cipher.getAuthTag(), body]).toString('base64')
124
+ },
125
+ open(packet) {
126
+ const raw = Buffer.from(String(packet), 'base64')
127
+ if (raw.length < 28) throw new Error('channel: short packet')
128
+ const nonce = raw.subarray(0, 12)
129
+ if (nonce[0] !== receiveDirection) throw new Error('channel: wrong direction')
130
+ const counter = nonce.readBigUInt64BE(4)
131
+ // Monotonic acceptance: a replayed or older packet is refused, while a
132
+ // gap left by a dropped message does not wedge the channel forever.
133
+ if (counter <= lastReceived) throw new Error('channel: replay')
134
+ const tag = raw.subarray(12, 28)
135
+ const body = raw.subarray(28)
136
+ const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 })
137
+ decipher.setAuthTag(tag)
138
+ const plain = Buffer.concat([decipher.update(body), decipher.final()])
139
+ lastReceived = counter
140
+ return plain
141
+ },
142
+ stats: () => ({ sent: sendCounter, received: lastReceived >= 0n ? Number(lastReceived) + 1 : 0 }),
143
+ reset() { sendCounter = 0; lastReceived = -1n },
144
+ }
145
+ }