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 +3 -3
- package/README.md +67 -18
- package/client.js +38 -0
- package/host/fabric.js +233 -30
- package/host/uploads.js +2 -1
- package/index.js +7 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
nothing — the target still requires the one-time approval before any session call is
|
|
58
|
-
Treat the relay as private: whoever holds the group id can join the group and *request*
|
|
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
|
-
-
|
|
94
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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.
|
|
911
|
-
|
|
912
|
-
|
|
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
|
-
|
|
1011
|
+
markLaneBlocked(deviceId, 'lan', result.error)
|
|
1012
|
+
return result
|
|
917
1013
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
930
|
-
return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
|
|
1057
|
+
return finish(direct, 'lan')
|
|
931
1058
|
}
|
|
932
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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', () => {
|
|
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:
|
|
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
|
-
},
|
|
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.
|
|
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": {
|