dsh-remote-control-plugin 0.4.0 → 0.4.1

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.4.0`, keep the official npm
7
+ Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.4.1`, 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.4.0` with plugin_manager.
12
+ > Install the DSH plugin bundle `dsh-remote-control-plugin@0.4.1` with plugin_manager.
13
13
 
14
14
  ## Connect two machines
15
15
 
@@ -27,7 +27,7 @@ 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.4.0 fixes: ordered transcript chunks, hosted relay persistence, stale
30
+ - 0.4.1 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
33
  remote session's model, permission preset, and plan mode. It also adds peer
package/README.md CHANGED
@@ -15,19 +15,29 @@ Both drive `ctx.sessionController` in-process, so neither depends on the GUI's b
15
15
 
16
16
  ## How device trust works
17
17
 
18
- 1. Each Host announces itself on UDP `discoveryPort` with its device id, display name, and
19
- account id (when signed in).
20
- 2. To control a peer, this Host sends `POST /peer/hello`. The **target** shows a pending
21
- request in its own Devices panel; nothing is granted until a person there presses **Allow**.
22
- 3. On approval the target issues a random per-peer secret. Every later call is signed
23
- `HMAC-SHA256(secret, deviceId + method + path + timestamp + body)`, valid for 5 minutes of
24
- clock skew.
25
- 4. Approvals persist in `$DSH_HOME/remote-control-peers.json`. Deleting that file revokes
26
- every device.
18
+ 1. Each Host generates a device identity once: an Ed25519 signing key and an X25519 agreement
19
+ key, stored as a credential-store record (a `0600` file only as a fallback). The fingerprint
20
+ is derived from the public key.
21
+ 2. Hosts announce themselves on UDP `discoveryPort` with device id, display name, and account id
22
+ (when signed in).
23
+ 3. To control a peer, this Host sends `POST /peer/hello` carrying its public key, fingerprint,
24
+ agreement key and a signed ephemeral key. The **target** shows a pending request in its own
25
+ Devices panel; nothing is granted until a person there presses **Allow** and the six-digit
26
+ short authentication string (SAS, derived from both fingerprints and the invite nonce) matches
27
+ on both screens.
28
+ 4. On approval the peer keys are pinned. Later calls carry an **Ed25519 signature** over method,
29
+ path, timestamp and a fresh nonce, with a five-minute skew window and a replay cache. The
30
+ legacy shared-secret HMAC is retired as soon as a key is pinned, because the approval response
31
+ that carried that secret crossed the relay in plaintext.
32
+ 5. Each approval records its scope list (`sessions:list`, `transcript:read`, `prompt:send`,
33
+ `stop`, `config:read`, `config:write`, `files:write`), the fingerprint, the agreement key and
34
+ the relay used. Scope edits and **Revoke** take effect immediately; revoking also drops the
35
+ cached keys.
36
+ 6. Approvals persist as credential-store grants; deleting the record revokes every device.
27
37
 
28
38
  There is deliberately no "same account implies trust" shortcut: two devices holding grants for
29
- the same account share no secret, so account id alone cannot authenticate a peer. The panel
30
- shows the account id of each candidate and flags a different account, but a person approves.
39
+ the same account share no secret, so account id alone cannot authenticate a peer. The panel shows
40
+ the account id of each candidate and flags a different account, but a person approves.
31
41
 
32
42
  ## Across networks (relay)
33
43
 
@@ -52,10 +62,32 @@ Host it on a VPS, NAS, or any always-on machine, put it behind TLS or a VPN, and
52
62
  `http://host:8787 <workspace key>` into every device.
53
63
 
54
64
  Either way the workspace key never leaves the devices: the relay only receives its SHA-256 as a
55
- group id. It forwards opaque `/peer/*` calls that stay signed end to end, so a compromised relay
56
- can drop or delay traffic but cannot forge a control command. Group membership alone grants
57
- nothing — the target still requires the one-time approval before any session call is accepted.
58
- Treat the relay as private: whoever holds the group id can join the group and *request* access.
65
+ group id. It forwards opaque `/peer/*` calls, and after pairing every message is sealed end to
66
+ end, so a compromised relay sees ciphertext and can only drop or delay traffic. Group membership
67
+ alone grants nothing — the target still requires the one-time approval before any session call is
68
+ accepted. Treat the relay as private: whoever holds the group id can join the group and *request*
69
+ access.
70
+
71
+ ### Encryption
72
+
73
+ Paired devices agree a session key with X25519 (ephemeral mixed with static when both sides
74
+ completed the pairing handshake) → HKDF-SHA256 → ChaCha20-Poly1305. The relay envelope seals every
75
+ message; on a direct LAN lane the request body and reply are sealed with a separate per-transport
76
+ key, and transcript frames with another. Plaintext exists only during bootstrap: the `hello`
77
+ request and the approval response that carries the peer's public keys.
78
+
79
+ A peer that pinned an identity but has no agreement key is **refused** over the relay
80
+ (`plaintext-refused`) rather than silently downgraded; `allowPlaintextFallback: true` opts out.
81
+ Peers approved before identity keys existed keep the legacy HMAC path until they are re-approved.
82
+
83
+ ### Lane ladder
84
+
85
+ Calls are attempted in order, each with its own budget: punched P2P (`p2pTimeoutMs`) → direct LAN
86
+ (`directTimeoutMs`) → relay. A blocked direct lane is parked per peer for `routeBlockMs` and then
87
+ retried as soon as fresh discovery evidence arrives. Latency-sensitive reads (`sessions`, session
88
+ config) race the relay against the direct lane so only the first call after a network change pays
89
+ a penalty; writes stay strictly ordered so a prompt can never run twice. `/state` reports `lane`,
90
+ `transport`, `timing` and `blocked` per peer.
59
91
 
60
92
  ## Configuration
61
93
 
@@ -78,6 +110,13 @@ Defaults work on a LAN. Override the row in `$DSH_HOME/profiles/<profile>/cordis
78
110
  cookieDays: 30
79
111
  defaultCwd: ''
80
112
  tls: { certFile: '', keyFile: '' }
113
+ directTimeoutMs: 1000 # budget for one direct LAN call before fallback
114
+ streamConnectTimeoutMs: 2500 # budget for direct stream headers before fallback
115
+ p2pTimeoutMs: 800 # budget for the punched lane per call
116
+ routeBlockMs: 60000 # how long a failed lane stays parked
117
+ punchPort: 57332 # UDP lane; also needs an inbound UDP firewall rule
118
+ stunHost: stun.l.google.com # or false to skip public mapping discovery
119
+ allowPlaintextFallback: false # refuse rather than downgrade a pinned peer
81
120
  ```
82
121
 
83
122
  The browser page and the peer API share the listener. When `host` is a wildcard, any IP literal
@@ -88,10 +127,20 @@ come from loopback.
88
127
  ## Threat model / limits
89
128
 
90
129
  - Approval is manual on purpose. A peer on the LAN can prompt for access; it cannot grant it.
130
+ Compare the six-digit SAS on both screens, and check the fingerprint when in doubt.
91
131
  - Prompts sent from a peer run with this Host's tools and permissions. Approve devices you
92
- control.
93
- - Peer transport is plain HTTP. The peer secret crosses the LAN once, at approval time. Use a
94
- private network or VPN, or set `tls` and front the port with a proxy.
132
+ control, and narrow scopes afterwards if a device only needs to read sessions.
133
+ - Transport is encrypted: sealed bodies/replies and transcript frames on a direct lane, an AEAD
134
+ envelope over the relay. The relay sees ids, timing and ciphertext.
135
+ - Legacy fallback: a peer approved before identity keys existed has no pinned key and still uses
136
+ the shared-secret HMAC in plaintext; a peer with a pinned identity but no agreement key is
137
+ refused over the relay unless `allowPlaintextFallback: true`. Re-approve to upgrade both.
138
+ - Forward secrecy is per session: ephemeral keys live in memory and are re-established by the
139
+ startup re-handshake, so a restart briefly degrades to static-static agreement.
140
+ - The construction is IK-shaped but **not Noise**: no protocol-name hash, no full handshake
141
+ transcript.
142
+ - Uploads are capped at 25 MB per file and 100 MB per session, with a MIME allowlist; bytes only
143
+ enter the Host attachment store, never a caller-supplied path.
95
144
  - The pairing code protects the browser page only; it is printed at activation and stored in
96
145
  `$DSH_HOME/remote-control.json`.
97
146
  - Approvals that arrive while no Devices panel is open stay pending for up to 10 minutes.
package/client.js CHANGED
@@ -515,6 +515,18 @@ window.__ModuleLoader__.load({
515
515
  refresh()
516
516
  }, [refresh])
517
517
 
518
+ const revoke = useCallback(async (deviceId) => {
519
+ setBusy(deviceId)
520
+ try {
521
+ await api('/remote-control/api/revoke', { deviceId })
522
+ setOpenPeer(null)
523
+ } catch (failure) {
524
+ setError(failure.message)
525
+ }
526
+ setBusy(null)
527
+ refresh()
528
+ }, [refresh])
529
+
518
530
  const open = useCallback(async (peer) => {
519
531
  setOpenPeer(peer)
520
532
  setSessions(null)
@@ -730,6 +742,32 @@ window.__ModuleLoader__.load({
730
742
  ),
731
743
  peer.approved
732
744
  ? h('span', { style: { ...S.chip, ...S.chipOk } }, 'Trusted')
745
+ : null,
746
+ peer.approved
747
+ ? h('span', {
748
+ style: { ...S.chip, ...(peer.forwardSecrecy ? S.chipOk : S.chipWarn) },
749
+ title: peer.forwardSecrecy ? 'Ephemeral channel established' : 'Only static agreement (restart or re-approve to refresh)',
750
+ }, peer.forwardSecrecy ? 'E2E' : 'static')
751
+ : null,
752
+ peer.approved && peer.fingerprint
753
+ ? h('span', { style: S.meta, title: `fingerprint ${peer.fingerprint}` }, String(peer.fingerprint).slice(0, 8))
754
+ : null,
755
+ peer.approved && Array.isArray(peer.scopes)
756
+ ? h('span', { style: S.meta, title: peer.scopes.join(', ') }, `${peer.scopes.length} scopes`)
757
+ : null,
758
+ peer.approved && peer.needsReapproval
759
+ ? h('span', {
760
+ style: { ...S.chip, ...S.chipBad },
761
+ title: 'This approval has no agreement key; approve again to enable encryption',
762
+ }, 'Re-approve')
763
+ : null,
764
+ peer.approved
765
+ ? h('button', {
766
+ type: 'button',
767
+ style: S.button,
768
+ disabled: busy === peer.deviceId,
769
+ onClick: (event) => { event.stopPropagation(); revoke(peer.deviceId) },
770
+ }, 'Revoke')
733
771
  : peer.connecting
734
772
  ? h('span', { style: { ...S.chip, ...S.chipWarn } }, 'Waiting')
735
773
  : h('button', {
package/host/fabric.js CHANGED
@@ -432,6 +432,8 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
432
432
  approved: approvals.has(message.deviceId) || Boolean(existing?.approved),
433
433
  secret: approvals.get(message.deviceId)?.secret ?? existing?.secret,
434
434
  })
435
+ // Fresh discovery evidence: retry the direct lane on the next call.
436
+ routeBlocks.delete(message.deviceId)
435
437
  }
436
438
 
437
439
  function reap() {
@@ -891,49 +893,186 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
891
893
  * One signed call to a peer over whichever transport reaches it: the relay
892
894
  * when the peer was discovered there, direct HTTP for a LAN peer.
893
895
  */
894
- async function peerCall(peer, method, pathname, body, secret) {
895
- const approval = approvals.get(peer.deviceId)
896
- const datagramLimit = 64 * 1024
896
+ const routeBlocks = new Map() // deviceId -> { lanUntil, p2pUntil, lastError }
897
+
898
+ function laneBlocked(deviceId, lane) {
899
+ const entry = routeBlocks.get(deviceId)
900
+ if (!entry) return false
901
+ return (lane === 'lan' ? entry.lanUntil : entry.p2pUntil) > Date.now()
902
+ }
903
+
904
+ function markLaneBlocked(deviceId, lane, reason) {
905
+ let entry = routeBlocks.get(deviceId)
906
+ if (!entry) { entry = { lanUntil: 0, p2pUntil: 0 }; routeBlocks.set(deviceId, entry) }
907
+ const holdMs = config.routeBlockMs ?? 60_000
908
+ if (lane === 'lan') entry.lanUntil = Date.now() + holdMs
909
+ else entry.p2pUntil = Date.now() + holdMs
910
+ entry.lastError = String(reason ?? 'unreachable')
911
+ log.warn(`[remote-control] ${lane} route to ${deviceId} parked for ${holdMs}ms: ${entry.lastError}`)
912
+ }
913
+
914
+ function markLaneHealthy(deviceId, lane) {
915
+ const entry = routeBlocks.get(deviceId)
916
+ if (!entry) return
917
+ if (lane === 'lan') entry.lanUntil = 0
918
+ else entry.p2pUntil = 0
919
+ }
920
+
921
+ function withTimeout(promise, ms) {
922
+ return new Promise((resolve) => {
923
+ const timer = setTimeout(() => resolve(undefined), ms)
924
+ timer.unref?.()
925
+ promise.then(
926
+ (value) => { clearTimeout(timer); resolve(value) },
927
+ () => { clearTimeout(timer); resolve(undefined) },
928
+ )
929
+ })
930
+ }
931
+
932
+ /** Resolve with the first non-undefined value; undefined when all fail. */
933
+ function firstSuccess(promises) {
934
+ return new Promise((resolve) => {
935
+ let pending = promises.length
936
+ let settled = false
937
+ for (const promise of promises) {
938
+ promise.then(
939
+ (value) => {
940
+ if (settled) return
941
+ if (value) { settled = true; resolve(value); return }
942
+ pending -= 1
943
+ if (pending === 0) resolve(undefined)
944
+ },
945
+ () => {
946
+ if (settled) return
947
+ pending -= 1
948
+ if (pending === 0) resolve(undefined)
949
+ },
950
+ )
951
+ }
952
+ })
953
+ }
954
+
955
+ async function peerCall(peer, method, pathname, body, secret, options = {}) {
956
+ const deviceId = peer.deviceId
957
+ const approval = approvals.get(deviceId)
897
958
  const bodySize = body === undefined ? 0 : JSON.stringify(body).length
898
- if (p2pNode && approval?.punch && bodySize <= datagramLimit) {
899
- const auth = authHeaders(peer.deviceId, method, pathname) ?? {}
900
- const result = await p2pNode.request(
959
+ const started = Date.now()
960
+ const relayAvailable = Boolean(relay)
961
+ const directParked = laneBlocked(deviceId, 'lan')
962
+ const p2pParked = laneBlocked(deviceId, 'p2p')
963
+
964
+ const finish = (result, lane) => {
965
+ const elapsed = Date.now() - started
966
+ peer.lastTransport = lane
967
+ peer.lastTiming = { lane, ms: elapsed, at: Date.now() }
968
+ log.info(`[remote-control] ${method} ${pathname} ${deviceId} via ${lane} in ${elapsed}ms`)
969
+ return result
970
+ }
971
+
972
+ const viaRelayLane = () => relayCall(peer, method, pathname, body, secret)
973
+
974
+ const viaP2p = async () => {
975
+ if (!p2pNode || !approval?.punch || bodySize > 64 * 1024 || laneBlocked(deviceId, 'p2p')) return undefined
976
+ const auth = authHeaders(deviceId, method, pathname) ?? {}
977
+ const result = await withTimeout(p2pNode.request(
901
978
  { address: approval.punch.address, port: approval.punch.port },
902
- peer.deviceId,
979
+ deviceId,
903
980
  {
904
981
  method,
905
982
  path: pathname,
906
983
  headers: { ...auth, accept: 'application/json', 'content-type': 'application/json' },
907
984
  bodyText: body === undefined ? '' : JSON.stringify(body),
908
985
  },
986
+ ), config.p2pTimeoutMs ?? 800)
987
+ if (!result?.ok) {
988
+ markLaneBlocked(deviceId, 'p2p', result?.error ?? 'p2p-timeout')
989
+ return undefined
990
+ }
991
+ markLaneHealthy(deviceId, 'p2p')
992
+ let json = {}
993
+ try { json = result.message.bodyText ? JSON.parse(result.message.bodyText) : {} } catch { json = {} }
994
+ return { ok: result.message.status >= 200 && result.message.status < 300, status: result.message.status, json }
995
+ }
996
+
997
+ const viaDirect = async () => {
998
+ const auth = authHeaders(deviceId, method, pathname)
999
+ const pair = lanCipherFor(deviceId)
1000
+ const lane = pair
1001
+ ? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
1002
+ : undefined
1003
+ const result = await callRaw(
1004
+ peer, method, pathname, body, secret, self().deviceId, auth, lane,
1005
+ config.directTimeoutMs ?? 1000,
909
1006
  )
910
- if (result.ok) {
911
- peer.lastTransport = 'p2p'
912
- let json = {}
913
- try { json = result.message.bodyText ? JSON.parse(result.message.bodyText) : {} } catch { json = {} }
914
- return { ok: result.message.status >= 200 && result.message.status < 300, status: result.message.status, json }
1007
+ if (!result.error) {
1008
+ markLaneHealthy(deviceId, 'lan')
1009
+ return result
915
1010
  }
916
- // A failed datagram path falls through to LAN and then the relay.
1011
+ markLaneBlocked(deviceId, 'lan', result.error)
1012
+ return result
917
1013
  }
918
- if (peer.proto !== 'relay') {
919
- const auth = authHeaders(peer.deviceId, method, pathname)
920
- const pair = lanCipherFor(peer.deviceId)
1014
+
1015
+ // Happy-eyeballs for idempotent reads: the relay and the direct lane start
1016
+ // together and the first answer wins. Never used for writes, so a prompt
1017
+ // can still execute exactly once.
1018
+ if (options.race && relayAvailable && peer.proto !== 'relay' && !directParked && !p2pParked) {
1019
+ const auth = authHeaders(deviceId, method, pathname)
1020
+ const pair = lanCipherFor(deviceId)
921
1021
  const lane = pair
922
1022
  ? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
923
1023
  : undefined
924
- const direct = await callRaw(peer, method, pathname, body, secret, self().deviceId, auth, lane)
925
- if (!direct.error) {
926
- peer.lastTransport = 'lan'
927
- return direct
1024
+ const directPromise = callRaw(
1025
+ peer, method, pathname, body, secret, self().deviceId, auth, lane,
1026
+ config.directTimeoutMs ?? 1000,
1027
+ ).then((result) => {
1028
+ if (result.error) { markLaneBlocked(deviceId, 'lan', result.error); return undefined }
1029
+ markLaneHealthy(deviceId, 'lan')
1030
+ return { lane: 'lan', result }
1031
+ }, () => undefined)
1032
+ const relayPromise = relayCall(peer, method, pathname, body, secret)
1033
+ .then((result) => ((result.ok || result.status !== 503) ? { lane: 'relay', result } : undefined), () => undefined)
1034
+ const winner = await firstSuccess([directPromise, relayPromise])
1035
+ if (winner) return finish(winner.result, winner.lane)
1036
+ }
1037
+
1038
+ // Relay-first once a direct lane is known blocked: only the first call pays
1039
+ // the discovery penalty. A fresh announce clears the block again.
1040
+ if (relayAvailable && peer.proto !== 'relay' && (directParked || p2pParked)) {
1041
+ const relayed = await viaRelayLane()
1042
+ if (relayed.ok || relayed.status !== 503) return finish(relayed, 'relay')
1043
+ const fallback = await viaDirect()
1044
+ if (!fallback.error) return finish(fallback, 'lan')
1045
+ return finish(relayed, 'relay')
1046
+ }
1047
+
1048
+ const datagram = await viaP2p()
1049
+ if (datagram) return finish(datagram, 'p2p')
1050
+ if (peer.proto !== 'relay') {
1051
+ const direct = await viaDirect()
1052
+ if (!direct.error) return finish(direct, 'lan')
1053
+ if (relayAvailable) {
1054
+ const relayed = await viaRelayLane()
1055
+ if (relayed.ok || relayed.status !== 503) return finish(relayed, 'relay')
928
1056
  }
929
- const viaRelay = await relayCall(peer, method, pathname, body, secret)
930
- return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
1057
+ return finish(direct, 'lan')
931
1058
  }
932
- return relayCall(peer, method, pathname, body, secret)
1059
+ return finish(await viaRelayLane(), 'relay')
933
1060
  }
934
1061
 
935
1062
  async function relayCall(peer, method, pathname, body, secret) {
936
1063
  if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
1064
+ // A peer that pinned an identity key but has no agreement key would send
1065
+ // plaintext through the relay: that is a silent downgrade, so refuse it
1066
+ // unless the operator opted in explicitly.
1067
+ const approvalForCrypto = approvals.get(peer.deviceId)
1068
+ if (approvalForCrypto?.publicKey && !approvalForCrypto?.agreementKey && config.allowPlaintextFallback !== true) {
1069
+ log.warn(`[remote-control] refusing plaintext relay call to ${peer.deviceId}: no agreement key`)
1070
+ return {
1071
+ ok: false,
1072
+ status: 403,
1073
+ json: { error: 'plaintext-refused', message: 'This peer pinned an identity but no encryption key; approve it again to enable encryption.' },
1074
+ }
1075
+ }
937
1076
  peer.lastTransport = 'relay'
938
1077
  const callerId = self().deviceId
939
1078
  const timestamp = String(Date.now())
@@ -963,13 +1102,13 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
963
1102
  return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
964
1103
  }
965
1104
 
966
- async function callPeer(deviceId, method, pathname, body) {
1105
+ async function callPeer(deviceId, method, pathname, body, options) {
967
1106
  syncRelayPeers()
968
1107
  const peer = peers.get(deviceId)
969
1108
  if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
970
1109
  const approval = approvals.get(deviceId)
971
1110
  if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
972
- return peerCall(peer, method, pathname, body, approval.secret)
1111
+ return peerCall(peer, method, pathname, body, approval.secret, options)
973
1112
  }
974
1113
 
975
1114
  /** Relay one peer's SSE transcript to a local response (Devices panel). */
@@ -984,7 +1123,24 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
984
1123
  const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
985
1124
  const timestamp = String(Date.now())
986
1125
  const callerId = self().deviceId
987
- if (config.p2pStreams !== false && p2pNode && approval.punch) {
1126
+ const started = Date.now()
1127
+ // A parked direct route goes straight to the relay: the stream must never
1128
+ // hang waiting for headers a blocked LAN will not deliver.
1129
+ if (relay && peer.proto !== 'relay' && laneBlocked(deviceId, 'lan')) {
1130
+ relay.stream(peer.deviceId, {
1131
+ path: target,
1132
+ headers: {
1133
+ accept: 'text/event-stream',
1134
+ 'x-dsh-peer-id': callerId,
1135
+ 'x-dsh-peer-ts': timestamp,
1136
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1137
+ ...(authHeaders(deviceId, 'GET', target) ?? {}),
1138
+ },
1139
+ }, res)
1140
+ peer.lastTiming = { lane: 'relay', ms: 0, at: Date.now(), reason: 'direct-parked' }
1141
+ return
1142
+ }
1143
+ if (config.p2pStreams !== false && p2pNode && approval.punch && !laneBlocked(deviceId, 'p2p')) {
988
1144
  const auth = authHeaders(deviceId, 'GET', target) ?? {}
989
1145
  res.writeHead(200, {
990
1146
  'content-type': 'text/event-stream; charset=utf-8',
@@ -1002,6 +1158,7 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1002
1158
  },
1003
1159
  )
1004
1160
  if (!handle.ok) {
1161
+ markLaneBlocked(deviceId, 'p2p', 'stream-open-failed')
1005
1162
  try { res.destroy() } catch { /* ignore */ }
1006
1163
  return
1007
1164
  }
@@ -1013,6 +1170,11 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1013
1170
  json(res, 503, { error: 'relay-offline' })
1014
1171
  return
1015
1172
  }
1173
+ if (approval.publicKey && !approval.agreementKey && config.allowPlaintextFallback !== true) {
1174
+ log.warn(`[remote-control] refusing plaintext relay stream to ${deviceId}: no agreement key`)
1175
+ json(res, 403, { error: 'plaintext-refused' })
1176
+ return
1177
+ }
1016
1178
  relay.stream(peer.deviceId, {
1017
1179
  path: target,
1018
1180
  headers: {
@@ -1042,6 +1204,10 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1042
1204
  method: 'GET',
1043
1205
  headers: streamHeaders,
1044
1206
  }, (response) => {
1207
+ responded = true
1208
+ clearTimeout(connectTimer)
1209
+ markLaneHealthy(deviceId, 'lan')
1210
+ peer.lastTiming = { lane: 'lan', ms: Date.now() - started, at: Date.now() }
1045
1211
  res.writeHead(response.statusCode ?? 502, {
1046
1212
  'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
1047
1213
  'cache-control': 'no-store, no-transform',
@@ -1079,16 +1245,26 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1079
1245
  })
1080
1246
  response.on('end', () => { try { res.end() } catch { /* ignore */ } })
1081
1247
  })
1082
- upstream.on('error', () => {
1248
+ let responded = false
1249
+ const connectTimer = setTimeout(() => {
1250
+ if (!responded) upstream.destroy(new Error('stream-connect-timeout'))
1251
+ }, config.streamConnectTimeoutMs ?? 2500)
1252
+ connectTimer.unref?.()
1253
+ upstream.on('error', (error) => {
1254
+ clearTimeout(connectTimer)
1083
1255
  // Same fallback as peerCall: a blocked LAN route may still reach the
1084
1256
  // peer through the outbound relay.
1257
+ if (!responded) markLaneBlocked(deviceId, 'lan', error?.message || 'stream-failed')
1085
1258
  if (relay && !res.headersSent) {
1086
1259
  relay.stream(peer.deviceId, { path: target, headers: streamHeaders }, res)
1087
1260
  return
1088
1261
  }
1089
1262
  try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
1090
1263
  })
1091
- res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
1264
+ res.on('close', () => {
1265
+ clearTimeout(connectTimer)
1266
+ try { upstream.destroy() } catch { /* ignore */ }
1267
+ })
1092
1268
  upstream.end()
1093
1269
  }
1094
1270
 
@@ -1103,6 +1279,30 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1103
1279
  handlePeer,
1104
1280
  rehandshake: rehandshakePeer,
1105
1281
  attemptPunch: punchPeer,
1282
+ // Diagnostics used by the latency tests and by the Devices panel tooling.
1283
+ simulateAnnounce(deviceId, address, port) {
1284
+ onAnnounce(
1285
+ { kind: ANNOUNCE_KIND, deviceId, deviceName: `simulated-${deviceId}`, userId: null, port, proto: 'http' },
1286
+ { address },
1287
+ )
1288
+ },
1289
+ setPeerAddress(deviceId, address, port) {
1290
+ const peer = peers.get(deviceId)
1291
+ if (!peer) return false
1292
+ peer.address = address
1293
+ if (Number.isInteger(port)) peer.port = port
1294
+ return true
1295
+ },
1296
+ routeState(deviceId) {
1297
+ const entry = routeBlocks.get(deviceId)
1298
+ return entry ? { ...entry } : null
1299
+ },
1300
+ debugSetApproval(deviceId, patch) {
1301
+ const approval = approvals.get(deviceId)
1302
+ if (!approval) return false
1303
+ approvals.set(deviceId, { ...approval, ...patch })
1304
+ return true
1305
+ },
1106
1306
  async clearPunch(peerDeviceId) {
1107
1307
  const approval = approvals.get(peerDeviceId)
1108
1308
  if (!approval) return { ok: false, error: 'unknown-peer' }
@@ -1158,6 +1358,9 @@ export function createFabric({ log, config, self, sessions, stateFile, store, up
1158
1358
  forwardSecrecy: Boolean(approvals.get(peer.deviceId)?.peerEphemeral && ephemerals.has(peer.deviceId)),
1159
1359
  lane: selectLane({ sameLan: peer.proto !== 'relay', punched: Boolean(approvals.get(peer.deviceId)?.punch), relayAvailable: Boolean(relay) }),
1160
1360
  transport: peer.lastTransport ?? (peer.proto === 'relay' ? 'relay' : 'lan'),
1361
+ timing: peer.lastTiming ?? null,
1362
+ blocked: { lan: laneBlocked(peer.deviceId, 'lan'), p2p: laneBlocked(peer.deviceId, 'p2p') },
1363
+ needsReapproval: Boolean(approvals.get(peer.deviceId)?.publicKey && !approvals.get(peer.deviceId)?.agreementKey),
1161
1364
  punch: approvals.get(peer.deviceId)?.punch ?? null,
1162
1365
  candidates: Array.isArray(approvals.get(peer.deviceId)?.candidates) ? approvals.get(peer.deviceId).candidates.length : 0,
1163
1366
  punchPort: peer.punchPort ?? 0,
@@ -1191,7 +1394,7 @@ function sign(secret, deviceId, method, pathname, timestamp, body) {
1191
1394
  .digest('hex')
1192
1395
  }
1193
1396
 
1194
- function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane) {
1397
+ function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane, timeoutMs = 15000) {
1195
1398
  return new Promise((resolve) => {
1196
1399
  let text = body === undefined ? '' : JSON.stringify(body)
1197
1400
  if (lane && body !== undefined) {
@@ -1219,7 +1422,7 @@ function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, l
1219
1422
  path: pathname,
1220
1423
  method,
1221
1424
  headers,
1222
- timeout: 15000,
1425
+ timeout: timeoutMs,
1223
1426
  }, (res) => {
1224
1427
  let data = ''
1225
1428
  res.on('data', (chunk) => { data += chunk })
package/host/uploads.js CHANGED
@@ -30,6 +30,7 @@ export function createUploadManager({ getAttachments, limits = {}, log = console
30
30
  sessionQuotaBytes: limits.sessionQuotaBytes ?? 100 * 1024 * 1024,
31
31
  ttlMs: limits.ttlMs ?? 10 * 60 * 1000,
32
32
  allowedTypes: new Set(limits.allowedTypes ?? DEFAULT_TYPES),
33
+ reaperMs: limits.reaperMs ?? 60_000,
33
34
  }
34
35
 
35
36
  const uploads = new Map()
@@ -40,7 +41,7 @@ export function createUploadManager({ getAttachments, limits = {}, log = console
40
41
  for (const [uploadId, upload] of uploads) {
41
42
  if (now - upload.at > config.ttlMs) uploads.delete(uploadId)
42
43
  }
43
- }, 60_000)
44
+ }, config.reaperMs)
44
45
  reaper.unref?.()
45
46
 
46
47
  const fail = (status, error, message) => ({ status, error, message })
package/index.js CHANGED
@@ -563,7 +563,7 @@ export function apply(ctx, rawConfig) {
563
563
  const operation = match[2]
564
564
  if (operation === 'session-config' && req.method === 'GET') {
565
565
  const sessionId = String(url.searchParams.get('sessionId') || '')
566
- const reply = await fabric.callPeer(deviceId, 'GET', `/peer/session/config?sessionId=${encodeURIComponent(sessionId)}`)
566
+ const reply = await fabric.callPeer(deviceId, 'GET', `/peer/session/config?sessionId=${encodeURIComponent(sessionId)}`, undefined, { race: true })
567
567
  return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
568
568
  }
569
569
  if (operation === 'session-config' && req.method === 'POST') {
@@ -572,7 +572,7 @@ export function apply(ctx, rawConfig) {
572
572
  return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
573
573
  }
574
574
  if (operation === 'sessions' && req.method === 'GET') {
575
- const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions')
575
+ const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions', undefined, { race: true })
576
576
  return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
577
577
  }
578
578
  if (operation === 'stream' && req.method === 'GET') {
@@ -1019,6 +1019,11 @@ function normalizeConfig(raw = {}) {
1019
1019
  stunHost: raw?.stunHost === false ? false : (typeof raw?.stunHost === 'string' ? raw.stunHost : 'stun.l.google.com'),
1020
1020
  stunPort: Number.isInteger(raw?.stunPort) ? raw.stunPort : 19302,
1021
1021
  stunTimeoutMs: Number.isInteger(raw?.stunTimeoutMs) ? raw.stunTimeoutMs : 1500,
1022
+ directTimeoutMs: Number.isInteger(raw?.directTimeoutMs) ? raw.directTimeoutMs : 1000,
1023
+ streamConnectTimeoutMs: Number.isInteger(raw?.streamConnectTimeoutMs) ? raw.streamConnectTimeoutMs : 2500,
1024
+ p2pTimeoutMs: Number.isInteger(raw?.p2pTimeoutMs) ? raw.p2pTimeoutMs : 800,
1025
+ routeBlockMs: Number.isInteger(raw?.routeBlockMs) ? raw.routeBlockMs : 60_000,
1026
+ allowPlaintextFallback: raw?.allowPlaintextFallback === true,
1022
1027
  deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
1023
1028
  tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
1024
1029
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-control-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": {