dsh-remote-control-plugin 0.3.6 → 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 +5 -4
- package/README.md +67 -18
- package/client.js +163 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +923 -49
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +171 -0
- package/index.js +212 -30
- package/package.json +1 -1
package/host/fabric.js
CHANGED
|
@@ -3,12 +3,49 @@ import dgram from 'node:dgram'
|
|
|
3
3
|
import http from 'node:http'
|
|
4
4
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
+
import { sasCode, signPayload, verifyPayload } from './identity.js'
|
|
7
|
+
import { createCipherPair, createBoxCipher, deriveEphemeralKey, deriveSessionKey, ephemeralPublicPem, generateEphemeralKeypair } from './crypto.js'
|
|
8
|
+
import { mintEphemeral, verifyEphemeral } from './handshake.js'
|
|
9
|
+
import { localCandidates } from './nat.js'
|
|
10
|
+
import { createPuncher } from './punch.js'
|
|
11
|
+
import { createP2pNode } from './p2p.js'
|
|
12
|
+
import { selectLane } from './lane.js'
|
|
6
13
|
|
|
7
14
|
const ANNOUNCE_KIND = 'dsh-remote/announce'
|
|
8
15
|
const ANNOUNCE_INTERVAL_MS = 4000
|
|
9
16
|
const PEER_TTL_MS = 15000
|
|
10
17
|
const REQUEST_TTL_MS = 10 * 60 * 1000
|
|
11
18
|
const CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
19
|
+
// Upload chunks carry up to 1 MiB of base64 (~1.4 MiB of JSON), above the
|
|
20
|
+
// default body cap; the relay's own per-message cap stays at 2 MiB.
|
|
21
|
+
const UPLOAD_BODY_BYTES = 4 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/** Capability scopes; an approval without an explicit list keeps all of them. */
|
|
24
|
+
export const DEFAULT_SCOPES = [
|
|
25
|
+
'sessions:list',
|
|
26
|
+
'transcript:read',
|
|
27
|
+
'prompt:send',
|
|
28
|
+
'stop',
|
|
29
|
+
'config:read',
|
|
30
|
+
'config:write',
|
|
31
|
+
'files:write',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const SCOPE_BY_PATH = [
|
|
35
|
+
[/^\/peer\/sessions$/, () => 'sessions:list'],
|
|
36
|
+
[/^\/peer\/stream$/, () => 'transcript:read'],
|
|
37
|
+
[/^\/peer\/prompt$/, () => 'prompt:send'],
|
|
38
|
+
[/^\/peer\/cancel$/, () => 'stop'],
|
|
39
|
+
[/^\/peer\/session\/config$/, (method) => (method === 'GET' ? 'config:read' : 'config:write')],
|
|
40
|
+
[/^\/peer\/upload\//, () => 'files:write'],
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function scopeFor(method, pathname) {
|
|
44
|
+
for (const [pattern, pick] of SCOPE_BY_PATH) {
|
|
45
|
+
if (pattern.test(pathname)) return pick(method)
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
12
49
|
|
|
13
50
|
/**
|
|
14
51
|
* Peer fabric: LAN discovery, one-time approval, and the authenticated
|
|
@@ -17,7 +54,23 @@ const CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
|
17
54
|
* Everything here is plain Node so it can be exercised without Cordis:
|
|
18
55
|
* `sessions` is a small facade over the Host's session controller.
|
|
19
56
|
*/
|
|
20
|
-
export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
57
|
+
export function createFabric({ log, config, self, sessions, stateFile, store, uploads, identity, limits = {} }) {
|
|
58
|
+
const approvalStore = store ?? createFileStore(stateFile, log)
|
|
59
|
+
const rate = new Map() // deviceId -> { count, resetAt }
|
|
60
|
+
const rateLimit = {
|
|
61
|
+
windowMs: limits.rateWindowMs ?? 10_000,
|
|
62
|
+
max: limits.rateMax ?? 120,
|
|
63
|
+
}
|
|
64
|
+
function allowCall(deviceId) {
|
|
65
|
+
const now = Date.now()
|
|
66
|
+
const entry = rate.get(deviceId)
|
|
67
|
+
if (!entry || entry.resetAt <= now) {
|
|
68
|
+
rate.set(deviceId, { count: 1, resetAt: now + rateLimit.windowMs })
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
entry.count += 1
|
|
72
|
+
return entry.count <= rateLimit.max
|
|
73
|
+
}
|
|
21
74
|
const peers = new Map() // deviceId -> peer record
|
|
22
75
|
const requests = new Map() // requestId -> inbound approval request
|
|
23
76
|
const outgoing = new Map() // deviceId -> { requestId, at }
|
|
@@ -27,14 +80,287 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
27
80
|
let reaper
|
|
28
81
|
let loaded = false
|
|
29
82
|
let relay
|
|
83
|
+
const ciphers = new Map()
|
|
84
|
+
const seenNonces = new Map()
|
|
85
|
+
|
|
86
|
+
/** Reject a nonce already spent by this peer inside the clock window. */
|
|
87
|
+
function acceptNonce(deviceId, nonce) {
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
let bucket = seenNonces.get(deviceId)
|
|
90
|
+
if (!bucket) { bucket = new Map(); seenNonces.set(deviceId, bucket) }
|
|
91
|
+
for (const [key, expiry] of bucket) if (expiry <= now) bucket.delete(key)
|
|
92
|
+
if (bucket.has(nonce)) return false
|
|
93
|
+
bucket.set(nonce, now + CLOCK_SKEW_MS)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Per-call authentication: the device's Ed25519 key when the peer's key is
|
|
99
|
+
* pinned, otherwise the legacy shared-secret HMAC so 0.3.x peers keep working.
|
|
100
|
+
*/
|
|
101
|
+
function authHeaders(peerDeviceId, method, pathname) {
|
|
102
|
+
const approval = approvals.get(peerDeviceId)
|
|
103
|
+
const me = currentIdentity()
|
|
104
|
+
const timestamp = String(Date.now())
|
|
105
|
+
if (approval?.publicKey && me?.ed25519?.privateKey) {
|
|
106
|
+
const nonce = randomBytes(12).toString('hex')
|
|
107
|
+
const payload = `${self().deviceId}\n${method}\n${pathname}\n${timestamp}\n${nonce}`
|
|
108
|
+
return {
|
|
109
|
+
'x-dsh-peer-id': self().deviceId,
|
|
110
|
+
'x-dsh-peer-ts': timestamp,
|
|
111
|
+
'x-dsh-peer-nonce': nonce,
|
|
112
|
+
'x-dsh-peer-alg': 'ed25519',
|
|
113
|
+
'x-dsh-peer-sig': signPayload(me.ed25519.privateKey, payload),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (approval?.secret) {
|
|
117
|
+
return {
|
|
118
|
+
'x-dsh-peer-id': self().deviceId,
|
|
119
|
+
'x-dsh-peer-ts': timestamp,
|
|
120
|
+
'x-dsh-peer-sig': sign(approval.secret, self().deviceId, method, pathname, timestamp, ''),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const currentIdentity = () => (typeof identity === 'function' ? identity() : identity)
|
|
127
|
+
|
|
128
|
+
const ephemerals = new Map() // peerDeviceId -> local X25519 private key
|
|
129
|
+
const rotating = new Set()
|
|
130
|
+
|
|
131
|
+
/** Mint this side's ephemeral message for one pairing attempt. */
|
|
132
|
+
function mintFor(peerDeviceId, pairCode) {
|
|
133
|
+
const me = currentIdentity()
|
|
134
|
+
if (!me?.ed25519?.privateKey) return undefined
|
|
135
|
+
const keypair = generateEphemeralKeypair()
|
|
136
|
+
ephemerals.set(peerDeviceId, keypair.privateKey)
|
|
137
|
+
const message = mintEphemeral(me, self().deviceId, pairCode, keypair)
|
|
138
|
+
return message
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Session key: ephemeral+static when both sides agreed, else static-static. */
|
|
142
|
+
function sessionKeyFor(peerDeviceId, label) {
|
|
143
|
+
const me = currentIdentity()
|
|
144
|
+
const approval = approvals.get(peerDeviceId)
|
|
145
|
+
if (!me?.x25519?.privateKey || !approval?.agreementKey) return undefined
|
|
146
|
+
const ephemeralPrivate = ephemerals.get(peerDeviceId)
|
|
147
|
+
if (ephemeralPrivate && approval.peerEphemeral) {
|
|
148
|
+
try {
|
|
149
|
+
const key = deriveEphemeralKey({
|
|
150
|
+
ephemeralPrivate,
|
|
151
|
+
peerEphemeralPublicPem: approval.peerEphemeral,
|
|
152
|
+
staticPrivate: me.x25519.privateKey,
|
|
153
|
+
peerStaticPublicPem: approval.agreementKey,
|
|
154
|
+
myFingerprint: me.fingerprint,
|
|
155
|
+
peerFingerprint: approval.fingerprint,
|
|
156
|
+
label,
|
|
157
|
+
})
|
|
158
|
+
return key
|
|
159
|
+
} catch { /* fall back to static-static */ }
|
|
160
|
+
}
|
|
161
|
+
const fallback = deriveSessionKey({
|
|
162
|
+
privateKey: me.x25519.privateKey,
|
|
163
|
+
peerPublicKeyPem: approval.agreementKey,
|
|
164
|
+
myFingerprint: me.fingerprint,
|
|
165
|
+
peerFingerprint: approval.fingerprint,
|
|
166
|
+
label,
|
|
167
|
+
})
|
|
168
|
+
return fallback
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cipherFor(peerDeviceId) {
|
|
172
|
+
const me = currentIdentity()
|
|
173
|
+
if (!me?.x25519?.privateKey) return undefined
|
|
174
|
+
const approval = approvals.get(peerDeviceId)
|
|
175
|
+
if (!approval?.agreementKey || !approval?.fingerprint) return undefined
|
|
176
|
+
const cached = ciphers.get(peerDeviceId)
|
|
177
|
+
if (cached) return cached
|
|
178
|
+
try {
|
|
179
|
+
const key = sessionKeyFor(peerDeviceId, 'channel')
|
|
180
|
+
if (!key) return undefined
|
|
181
|
+
const side = String(self().deviceId) < String(peerDeviceId) ? 'a' : 'b'
|
|
182
|
+
const pair = createCipherPair(key, side)
|
|
183
|
+
ciphers.set(peerDeviceId, pair)
|
|
184
|
+
return pair
|
|
185
|
+
} catch (error) {
|
|
186
|
+
log.warn(`[remote-control] channel setup failed for ${peerDeviceId}: ${error?.message || error}`)
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lanCiphers = new Map()
|
|
192
|
+
const p2pBoxes = new Map()
|
|
193
|
+
let p2pNode
|
|
194
|
+
let publicEndpoint
|
|
195
|
+
|
|
196
|
+
/** Sealed box for the p2p datagram lane, keyed per peer. */
|
|
197
|
+
function p2pBoxFor(deviceId) {
|
|
198
|
+
const key = sessionKeyFor(deviceId, 'p2p')
|
|
199
|
+
if (!key) return undefined
|
|
200
|
+
let box = p2pBoxes.get(deviceId)
|
|
201
|
+
if (!box) {
|
|
202
|
+
box = createBoxCipher(key)
|
|
203
|
+
p2pBoxes.set(deviceId, box)
|
|
204
|
+
}
|
|
205
|
+
return box
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Per-peer AEAD pair for one direct transport lane (separate nonce space). */
|
|
209
|
+
function lanCipherFor(peerDeviceId, label = 'lan') {
|
|
210
|
+
const me = currentIdentity()
|
|
211
|
+
if (!me?.x25519?.privateKey) return undefined
|
|
212
|
+
const approval = approvals.get(peerDeviceId)
|
|
213
|
+
if (!approval?.agreementKey || !approval?.fingerprint) return undefined
|
|
214
|
+
const cacheKey = `${label}:${peerDeviceId}`
|
|
215
|
+
const cached = lanCiphers.get(cacheKey)
|
|
216
|
+
if (cached) return cached
|
|
217
|
+
try {
|
|
218
|
+
const key = sessionKeyFor(peerDeviceId, label)
|
|
219
|
+
if (!key) return undefined
|
|
220
|
+
const side = String(self().deviceId) < String(peerDeviceId) ? 'a' : 'b'
|
|
221
|
+
const pair = createCipherPair(key, side)
|
|
222
|
+
lanCiphers.set(cacheKey, pair)
|
|
223
|
+
return pair
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Mint an ephemeral message without installing it as the live key yet. */
|
|
230
|
+
function mintDetached(pairCode) {
|
|
231
|
+
const me = currentIdentity()
|
|
232
|
+
if (!me?.ed25519?.privateKey) return undefined
|
|
233
|
+
const keypair = generateEphemeralKeypair()
|
|
234
|
+
return { privateKey: keypair.privateKey, message: mintEphemeral(me, self().deviceId, pairCode, keypair) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Try to open a direct UDP path to one peer (both sides must call this). */
|
|
238
|
+
async function punchPeer(peerDeviceId) {
|
|
239
|
+
const approval = approvals.get(peerDeviceId)
|
|
240
|
+
if (!approval) return { ok: false, error: 'unknown-peer' }
|
|
241
|
+
const candidates = approval.candidates ?? []
|
|
242
|
+
const punchPort = approval.punchPort ?? 0
|
|
243
|
+
if (candidates.length === 0 || !punchPort) return { ok: false, error: 'no-candidates' }
|
|
244
|
+
if (!p2pNode) return { ok: false, error: 'p2p-unavailable' }
|
|
245
|
+
// The public mapping (STUN) is the only endpoint a NATed peer can answer on;
|
|
246
|
+
// LAN candidates are tried with the local punch port as a same-network path.
|
|
247
|
+
const targets = []
|
|
248
|
+
if (approval.publicEndpoint?.address) {
|
|
249
|
+
targets.push({ address: approval.publicEndpoint.address, port: approval.publicEndpoint.port })
|
|
250
|
+
}
|
|
251
|
+
for (const address of candidates) targets.push({ address, port: punchPort })
|
|
252
|
+
for (const target of targets) {
|
|
253
|
+
const result = await p2pNode.punch(target, peerDeviceId)
|
|
254
|
+
if (!result.ok) continue
|
|
255
|
+
approvals.set(peerDeviceId, { ...approval, punch: target })
|
|
256
|
+
await saveApprovals()
|
|
257
|
+
return { ok: true, address: target.address, port: target.port }
|
|
258
|
+
}
|
|
259
|
+
return { ok: false, error: 'no-pong' }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Rotate the ephemeral channel with one approved peer. */
|
|
263
|
+
async function rehandshakePeer(peerDeviceId) {
|
|
264
|
+
const peer = peers.get(peerDeviceId)
|
|
265
|
+
if (!peer) return { ok: false, error: 'unknown-peer' }
|
|
266
|
+
// Two simultaneous initiators would each install a different ephemeral and
|
|
267
|
+
// desynchronize the channel, so only one rotation may be in flight.
|
|
268
|
+
if (rotating.has(peerDeviceId)) return { ok: false, error: 'in-progress' }
|
|
269
|
+
rotating.add(peerDeviceId)
|
|
270
|
+
try {
|
|
271
|
+
const pairCode = self().pairCode ?? ''
|
|
272
|
+
const minted = mintDetached(pairCode)
|
|
273
|
+
if (!minted) return { ok: false, error: 'no-identity' }
|
|
274
|
+
// The request still travels over the current channel; the new key is
|
|
275
|
+
// installed only once the peer's signed reply verifies.
|
|
276
|
+
const result = await peerCall(peer, 'POST', '/peer/rehandshake', { ephemeral: minted.message })
|
|
277
|
+
if (!result.ok) return { ok: false, error: result.json?.error || result.error || 'rehandshake-failed' }
|
|
278
|
+
const theirs = result.json?.ephemeral
|
|
279
|
+
const approval = approvals.get(peerDeviceId)
|
|
280
|
+
if (!verifyEphemeral(approval?.publicKey ?? '', peerDeviceId, pairCode, theirs)) {
|
|
281
|
+
return { ok: false, error: 'bad-handshake' }
|
|
282
|
+
}
|
|
283
|
+
ephemerals.set(peerDeviceId, minted.privateKey)
|
|
284
|
+
approvals.set(peerDeviceId, { ...approval, peerEphemeral: theirs.publicPem, rotatedAt: Date.now() })
|
|
285
|
+
ciphers.delete(peerDeviceId)
|
|
286
|
+
for (const key of [...lanCiphers.keys()]) {
|
|
287
|
+
if (key.endsWith(`:${peerDeviceId}`)) lanCiphers.delete(key)
|
|
288
|
+
}
|
|
289
|
+
await saveApprovals()
|
|
290
|
+
return { ok: true }
|
|
291
|
+
} finally {
|
|
292
|
+
rotating.delete(peerDeviceId)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const security = {
|
|
297
|
+
seal(to, text) {
|
|
298
|
+
const pair = cipherFor(to)
|
|
299
|
+
if (!pair) return undefined
|
|
300
|
+
try { return pair.seal(text) } catch { return undefined }
|
|
301
|
+
},
|
|
302
|
+
open(from, packet) {
|
|
303
|
+
const pair = cipherFor(from)
|
|
304
|
+
if (!pair) return undefined
|
|
305
|
+
try { return pair.open(packet).toString('utf8') } catch (error) {
|
|
306
|
+
log.warn(`[remote-control] dropped an unauthenticated relay message from ${from}: ${error?.message || error}`)
|
|
307
|
+
return undefined
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
}
|
|
30
311
|
|
|
31
312
|
const discoveryPort = config.discoveryPort
|
|
32
313
|
const enabled = config.discovery !== false
|
|
33
314
|
|
|
34
315
|
async function start() {
|
|
35
316
|
await loadApprovals()
|
|
317
|
+
|
|
318
|
+
// The p2p lane must exist even when LAN discovery is disabled.
|
|
319
|
+
if (config.punchPort) {
|
|
320
|
+
try {
|
|
321
|
+
p2pNode = await createP2pNode({
|
|
322
|
+
localPort: config.port,
|
|
323
|
+
bindPort: config.punchPort,
|
|
324
|
+
selfDeviceId: self().deviceId,
|
|
325
|
+
log,
|
|
326
|
+
keyFor: (deviceId) => p2pBoxFor(deviceId),
|
|
327
|
+
})
|
|
328
|
+
} catch (error) {
|
|
329
|
+
log.warn(`[remote-control] could not open the p2p lane on ${config.punchPort}: ${error?.message || error}`)
|
|
330
|
+
}
|
|
331
|
+
// Learn this socket's public mapping so a NATed peer advertises something
|
|
332
|
+
// reachable. Without this only LAN addresses would be offered.
|
|
333
|
+
if (p2pNode && config.stunHost !== false) {
|
|
334
|
+
try {
|
|
335
|
+
const mapped = await p2pNode.mapped({
|
|
336
|
+
host: config.stunHost || 'stun.l.google.com',
|
|
337
|
+
port: config.stunPort ?? 19302,
|
|
338
|
+
timeoutMs: config.stunTimeoutMs ?? 1500,
|
|
339
|
+
})
|
|
340
|
+
if (mapped?.address) publicEndpoint = { address: mapped.address, port: mapped.port }
|
|
341
|
+
} catch { /* no STUN reachable; LAN candidates still work */ }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
36
344
|
if (!enabled) return
|
|
37
345
|
|
|
346
|
+
// Ephemeral keys live only in memory, so after a restart each approved peer
|
|
347
|
+
// is re-handshaked once reachable; until then the channel uses static-static.
|
|
348
|
+
const refreshMs = Number.isInteger(config.channelRefreshMs) ? config.channelRefreshMs : 2500
|
|
349
|
+
const refreshTimer = refreshMs > 0 ? setTimeout(() => {
|
|
350
|
+
for (const deviceId of approvals.keys()) {
|
|
351
|
+
// Only peers without an in-memory ephemeral need a rotation; this is
|
|
352
|
+
// the restart case, not a periodic re-key.
|
|
353
|
+
if (ephemerals.has(deviceId)) continue
|
|
354
|
+
// Deterministic initiator: only the lower device id starts an automatic
|
|
355
|
+
// rotation, so the two sides never rotate over each other.
|
|
356
|
+
if (String(self().deviceId) >= String(deviceId)) continue
|
|
357
|
+
const rotatedAt = approvals.get(deviceId)?.rotatedAt
|
|
358
|
+
if (rotatedAt && Date.now() - rotatedAt < 60_000) continue
|
|
359
|
+
rehandshakePeer(deviceId).catch(() => { /* peer offline */ })
|
|
360
|
+
}
|
|
361
|
+
}, refreshMs) : undefined
|
|
362
|
+
refreshTimer?.unref?.()
|
|
363
|
+
|
|
38
364
|
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
39
365
|
sockets.push(socket)
|
|
40
366
|
socket.on('error', (error) => log.warn(`[remote-control] discovery socket: ${error?.message || error}`))
|
|
@@ -60,6 +386,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
60
386
|
function stop() {
|
|
61
387
|
clearInterval(announceTimer)
|
|
62
388
|
clearInterval(reaper)
|
|
389
|
+
try { p2pNode?.close() } catch { /* already closed */ }
|
|
390
|
+
p2pNode = undefined
|
|
391
|
+
p2pBoxes.clear()
|
|
63
392
|
for (const socket of sockets) {
|
|
64
393
|
try { socket.close() } catch { /* ignore */ }
|
|
65
394
|
}
|
|
@@ -103,6 +432,8 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
103
432
|
approved: approvals.has(message.deviceId) || Boolean(existing?.approved),
|
|
104
433
|
secret: approvals.get(message.deviceId)?.secret ?? existing?.secret,
|
|
105
434
|
})
|
|
435
|
+
// Fresh discovery evidence: retry the direct lane on the next call.
|
|
436
|
+
routeBlocks.delete(message.deviceId)
|
|
106
437
|
}
|
|
107
438
|
|
|
108
439
|
function reap() {
|
|
@@ -150,13 +481,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
150
481
|
if (loaded) return
|
|
151
482
|
loaded = true
|
|
152
483
|
try {
|
|
153
|
-
const raw =
|
|
484
|
+
const raw = await approvalStore.load()
|
|
154
485
|
for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
|
|
155
486
|
if (entry && typeof entry.secret === 'string') {
|
|
156
487
|
approvals.set(deviceId, {
|
|
157
488
|
deviceName: entry.deviceName ?? 'DSH device',
|
|
158
489
|
secret: entry.secret,
|
|
159
490
|
approvedAt: entry.approvedAt ?? Date.now(),
|
|
491
|
+
relay: entry.relay ?? '',
|
|
492
|
+
publicKey: entry.publicKey ?? '',
|
|
493
|
+
fingerprint: entry.fingerprint ?? '',
|
|
494
|
+
agreementKey: entry.agreementKey ?? '',
|
|
495
|
+
peerEphemeral: entry.peerEphemeral ?? '',
|
|
496
|
+
sas: entry.sas ?? '',
|
|
497
|
+
scopes: Array.isArray(entry.scopes) ? entry.scopes : [...DEFAULT_SCOPES],
|
|
160
498
|
})
|
|
161
499
|
}
|
|
162
500
|
}
|
|
@@ -169,10 +507,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
169
507
|
const peersObject = {}
|
|
170
508
|
for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
|
|
171
509
|
try {
|
|
172
|
-
await
|
|
173
|
-
await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
|
|
510
|
+
await approvalStore.save({ version: 1, peers: peersObject })
|
|
174
511
|
} catch (error) {
|
|
175
|
-
log.warn(`[remote-control] could not persist
|
|
512
|
+
log.warn(`[remote-control] could not persist approvals: ${error?.message || error}`)
|
|
176
513
|
}
|
|
177
514
|
}
|
|
178
515
|
|
|
@@ -184,11 +521,38 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
184
521
|
if (!deviceId) return json(res, 400, { error: 'missing-device' })
|
|
185
522
|
if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
|
|
186
523
|
const requestId = randomBytes(16).toString('hex')
|
|
524
|
+
const claimedRelay = typeof body?.relay === 'string' ? body.relay : ''
|
|
525
|
+
const claimedPairCode = typeof body?.pairCode === 'string' ? body.pairCode : ''
|
|
526
|
+
const claimedFingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : ''
|
|
527
|
+
const claimedKey = typeof body?.publicKey === 'string' ? body.publicKey : ''
|
|
528
|
+
const known = approvals.get(deviceId)
|
|
529
|
+
const who = self()
|
|
530
|
+
const claimedEphemeral = body?.ephemeral
|
|
531
|
+
const ephemeralOk = verifyEphemeral(claimedKey, deviceId, claimedPairCode, claimedEphemeral)
|
|
532
|
+
const replyEphemeral = mintFor(deviceId, claimedPairCode)
|
|
187
533
|
requests.set(requestId, {
|
|
188
534
|
requestId,
|
|
189
535
|
deviceId,
|
|
190
536
|
deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
|
|
191
537
|
userId: typeof body.userId === 'string' ? body.userId : null,
|
|
538
|
+
relay: claimedRelay,
|
|
539
|
+
pairCode: claimedPairCode,
|
|
540
|
+
publicKey: claimedKey,
|
|
541
|
+
fingerprint: claimedFingerprint,
|
|
542
|
+
peerEphemeral: ephemeralOk ? claimedEphemeral.publicPem : '',
|
|
543
|
+
candidates: Array.isArray(body?.candidates) ? body.candidates.filter((entry) => typeof entry === 'string').slice(0, 16) : [],
|
|
544
|
+
punchPort: Number.isInteger(body?.punchPort) ? body.punchPort : 0,
|
|
545
|
+
publicEndpoint: body?.publicEndpoint && typeof body.publicEndpoint.address === 'string' && Number.isInteger(body.publicEndpoint.port)
|
|
546
|
+
? { address: body.publicEndpoint.address, port: body.publicEndpoint.port }
|
|
547
|
+
: null,
|
|
548
|
+
replyEphemeral,
|
|
549
|
+
agreementKey: typeof body?.agreementKey === 'string' ? body.agreementKey : '',
|
|
550
|
+
// Both screens show these digits only when both keys and the invite
|
|
551
|
+
// nonce agree; a mismatch is a hard stop, not a warning.
|
|
552
|
+
sas: claimedFingerprint && who.fingerprint
|
|
553
|
+
? sasCode(who.fingerprint, claimedFingerprint, claimedPairCode)
|
|
554
|
+
: '',
|
|
555
|
+
relayChanged: Boolean(known?.relay && claimedRelay && known.relay !== claimedRelay),
|
|
192
556
|
address: req.socket?.remoteAddress ?? '',
|
|
193
557
|
at: Date.now(),
|
|
194
558
|
status: 'pending',
|
|
@@ -201,10 +565,26 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
201
565
|
function helloStatus(res, requestId) {
|
|
202
566
|
const request = requests.get(requestId)
|
|
203
567
|
if (!request) return json(res, 404, { error: 'unknown-request' })
|
|
568
|
+
const who = self()
|
|
204
569
|
if (request.status === 'approved') {
|
|
205
|
-
return json(res, 200, {
|
|
570
|
+
return json(res, 200, {
|
|
571
|
+
status: 'approved',
|
|
572
|
+
// A peer that pinned a device key never needs the shared secret, and
|
|
573
|
+
// the relay would see this response in plaintext. Only legacy peers
|
|
574
|
+
// (no claimed public key) still receive one.
|
|
575
|
+
...(request.publicKey ? {} : { secret: request.secret }),
|
|
576
|
+
deviceName: who.deviceName,
|
|
577
|
+
publicKey: who.publicKey ?? '',
|
|
578
|
+
fingerprint: who.fingerprint ?? '',
|
|
579
|
+
agreementKey: who.agreementKey ?? '',
|
|
580
|
+
ephemeral: request.replyEphemeral,
|
|
581
|
+
candidates: localCandidates().map((entry) => entry.address),
|
|
582
|
+
punchPort: config.punchPort ?? 0,
|
|
583
|
+
publicEndpoint: publicEndpoint ?? null,
|
|
584
|
+
sas: request.sas ?? '',
|
|
585
|
+
})
|
|
206
586
|
}
|
|
207
|
-
return json(res, 200, { status: request.status })
|
|
587
|
+
return json(res, 200, { status: request.status, sas: request.sas ?? '' })
|
|
208
588
|
}
|
|
209
589
|
|
|
210
590
|
async function approveRequest(requestId) {
|
|
@@ -213,7 +593,22 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
213
593
|
const secret = randomBytes(32).toString('hex')
|
|
214
594
|
request.status = 'approved'
|
|
215
595
|
request.secret = secret
|
|
216
|
-
approvals.set(request.deviceId, {
|
|
596
|
+
approvals.set(request.deviceId, {
|
|
597
|
+
deviceName: request.deviceName,
|
|
598
|
+
secret,
|
|
599
|
+
approvedAt: Date.now(),
|
|
600
|
+
relay: request.relay ?? '',
|
|
601
|
+
publicKey: request.publicKey ?? '',
|
|
602
|
+
fingerprint: request.fingerprint ?? '',
|
|
603
|
+
agreementKey: request.agreementKey ?? '',
|
|
604
|
+
peerEphemeral: request.peerEphemeral ?? '',
|
|
605
|
+
candidates: Array.isArray(request.candidates) ? request.candidates : [],
|
|
606
|
+
punchPort: Number.isInteger(request.punchPort) ? request.punchPort : 0,
|
|
607
|
+
publicEndpoint: request.publicEndpoint ?? null,
|
|
608
|
+
secret: request.publicKey ? '' : request.secret,
|
|
609
|
+
sas: request.sas ?? '',
|
|
610
|
+
scopes: [...DEFAULT_SCOPES],
|
|
611
|
+
})
|
|
217
612
|
await saveApprovals()
|
|
218
613
|
const peer = peers.get(request.deviceId)
|
|
219
614
|
if (peer) { peer.approved = true; peer.secret = secret }
|
|
@@ -241,6 +636,15 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
241
636
|
deviceId: who.deviceId,
|
|
242
637
|
deviceName: who.deviceName,
|
|
243
638
|
userId: who.userId,
|
|
639
|
+
relay: who.relayUrl ?? '',
|
|
640
|
+
pairCode: who.pairCode ?? '',
|
|
641
|
+
publicKey: who.publicKey ?? '',
|
|
642
|
+
fingerprint: who.fingerprint ?? '',
|
|
643
|
+
agreementKey: who.agreementKey ?? '',
|
|
644
|
+
ephemeral: mintFor(peer.deviceId, who.pairCode ?? ''),
|
|
645
|
+
candidates: localCandidates().map((entry) => entry.address),
|
|
646
|
+
punchPort: config.punchPort ?? 0,
|
|
647
|
+
publicEndpoint: publicEndpoint ?? null,
|
|
244
648
|
})
|
|
245
649
|
if (!reply.ok || typeof reply.json?.requestId !== 'string') {
|
|
246
650
|
return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
|
|
@@ -258,11 +662,29 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
258
662
|
if (!outgoing.has(peer.deviceId)) return
|
|
259
663
|
const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
|
|
260
664
|
const status = reply.json?.status
|
|
261
|
-
if (status === 'approved'
|
|
262
|
-
|
|
665
|
+
if (status === 'approved') {
|
|
666
|
+
const replyEphemeral = reply.json.ephemeral
|
|
667
|
+
const ephemeralOk = verifyEphemeral(reply.json.publicKey ?? '', peer.deviceId, self().pairCode ?? '', replyEphemeral)
|
|
668
|
+
approvals.set(peer.deviceId, {
|
|
669
|
+
deviceName: peer.deviceName,
|
|
670
|
+
secret: reply.json.secret ?? '',
|
|
671
|
+
approvedAt: Date.now(),
|
|
672
|
+
relay: relay?.url() ?? '',
|
|
673
|
+
publicKey: reply.json.publicKey ?? '',
|
|
674
|
+
fingerprint: reply.json.fingerprint ?? '',
|
|
675
|
+
agreementKey: reply.json.agreementKey ?? '',
|
|
676
|
+
peerEphemeral: ephemeralOk ? replyEphemeral.publicPem : '',
|
|
677
|
+
candidates: Array.isArray(reply.json.candidates) ? reply.json.candidates : [],
|
|
678
|
+
punchPort: Number.isInteger(reply.json.punchPort) ? reply.json.punchPort : 0,
|
|
679
|
+
publicEndpoint: reply.json.publicEndpoint ?? null,
|
|
680
|
+
sas: reply.json.sas ?? '',
|
|
681
|
+
scopes: [...DEFAULT_SCOPES],
|
|
682
|
+
})
|
|
683
|
+
peer.publicKey = reply.json.publicKey ?? ''
|
|
684
|
+
peer.sas = reply.json.sas ?? ''
|
|
263
685
|
await saveApprovals()
|
|
264
686
|
peer.approved = true
|
|
265
|
-
peer.secret = reply.json.secret
|
|
687
|
+
peer.secret = reply.json.secret ?? ''
|
|
266
688
|
outgoing.delete(peer.deviceId)
|
|
267
689
|
log.info(`[remote-control] ${peer.deviceName} approved this device`)
|
|
268
690
|
return
|
|
@@ -285,42 +707,105 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
285
707
|
|
|
286
708
|
const verdict = verifyPeer(req, url)
|
|
287
709
|
if (!verdict.ok) return json(res, 403, { error: verdict.error })
|
|
710
|
+
if (!allowCall(verdict.deviceId)) return json(res, 429, { error: 'rate-limited' })
|
|
711
|
+
const requiredScope = scopeFor(req.method || 'GET', pathname)
|
|
712
|
+
if (requiredScope && Array.isArray(verdict.approval?.scopes) && !verdict.approval.scopes.includes(requiredScope)) {
|
|
713
|
+
return json(res, 403, { error: 'scope-denied', scope: requiredScope })
|
|
714
|
+
}
|
|
288
715
|
|
|
716
|
+
const viaHeader = String(req.headers['x-dsh-via'] || '')
|
|
717
|
+
const fromRelay = viaHeader === 'relay' || viaHeader === 'p2p'
|
|
718
|
+
// A pinned peer on the direct transport must use the sealed lane; relay
|
|
719
|
+
// traffic is already inside the relay AEAD envelope.
|
|
720
|
+
let lanePair
|
|
721
|
+
if (!fromRelay && verdict.approval?.publicKey && pathname !== '/peer/stream') {
|
|
722
|
+
if (String(req.headers['x-dsh-lane'] || '') !== 'sealed') {
|
|
723
|
+
return json(res, 403, { error: 'plaintext-not-allowed' })
|
|
724
|
+
}
|
|
725
|
+
lanePair = lanCipherFor(verdict.deviceId)
|
|
726
|
+
if (!lanePair) return json(res, 403, { error: 'no-channel' })
|
|
727
|
+
}
|
|
728
|
+
// One body read for every authenticated POST: the peer may have sealed it
|
|
729
|
+
// with the LAN channel, and every route needs the same decoded value.
|
|
730
|
+
let body = {}
|
|
731
|
+
if (req.method === 'POST') {
|
|
732
|
+
let incoming
|
|
733
|
+
try {
|
|
734
|
+
incoming = await readJson(req, UPLOAD_BODY_BYTES)
|
|
735
|
+
} catch {
|
|
736
|
+
return json(res, 400, { error: 'bad-body' })
|
|
737
|
+
}
|
|
738
|
+
if (incoming && typeof incoming === 'object' && typeof incoming.enc === 'string') {
|
|
739
|
+
if (!lanePair) return json(res, 403, { error: 'no-channel' })
|
|
740
|
+
try {
|
|
741
|
+
body = JSON.parse(lanePair.open(incoming.enc).toString('utf8'))
|
|
742
|
+
} catch (error) {
|
|
743
|
+
return json(res, 403, { error: 'bad-envelope' })
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
body = incoming ?? {}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const reply = lanePair
|
|
750
|
+
? (status, value) => json(res, status, { enc: lanePair.seal(JSON.stringify(value)) })
|
|
751
|
+
: (status, value) => json(res, status, value)
|
|
752
|
+
|
|
753
|
+
if (pathname.startsWith('/peer/upload/') && uploads) {
|
|
754
|
+
const action = pathname.slice('/peer/upload/'.length)
|
|
755
|
+
const handler = uploads[action]
|
|
756
|
+
if (typeof handler !== 'function') return reply(404, { error: 'not-found' })
|
|
757
|
+
const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams.entries()) : body
|
|
758
|
+
const result = await handler(payload)
|
|
759
|
+
return reply(result.status ?? 200, result)
|
|
760
|
+
}
|
|
761
|
+
if (pathname === '/peer/rehandshake' && req.method === 'POST') {
|
|
762
|
+
const peerMessage = body?.ephemeral
|
|
763
|
+
const mine = approvals.get(verdict.deviceId)
|
|
764
|
+
if (!peerMessage || !verifyEphemeral(mine?.publicKey ?? '', verdict.deviceId, self().pairCode ?? '', peerMessage)) {
|
|
765
|
+
return reply(400, { error: 'bad-handshake' })
|
|
766
|
+
}
|
|
767
|
+
const replyMessage = mintFor(verdict.deviceId, self().pairCode ?? '')
|
|
768
|
+
approvals.set(verdict.deviceId, { ...mine, peerEphemeral: peerMessage.publicPem })
|
|
769
|
+
ciphers.delete(verdict.deviceId)
|
|
770
|
+
for (const key of [...lanCiphers.keys()]) {
|
|
771
|
+
if (key.endsWith(`:${verdict.deviceId}`)) lanCiphers.delete(key)
|
|
772
|
+
}
|
|
773
|
+
await saveApprovals()
|
|
774
|
+
return reply(200, { ok: true, ephemeral: replyMessage })
|
|
775
|
+
}
|
|
289
776
|
if (pathname === '/peer/session/config' && req.method === 'GET') {
|
|
290
777
|
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
291
|
-
if (!sessionId) return
|
|
292
|
-
if (typeof sessions.config !== 'function') return
|
|
293
|
-
return
|
|
778
|
+
if (!sessionId) return reply(400, { error: 'missing-session' })
|
|
779
|
+
if (typeof sessions.config !== 'function') return reply(503, { error: 'config-unavailable' })
|
|
780
|
+
return reply(200, await sessions.config(sessionId))
|
|
294
781
|
}
|
|
295
782
|
if (pathname === '/peer/session/config' && req.method === 'POST') {
|
|
296
|
-
const body = await readJson(req)
|
|
297
783
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
298
|
-
if (!sessionId) return
|
|
299
|
-
if (typeof sessions.applyConfig !== 'function') return
|
|
300
|
-
return
|
|
784
|
+
if (!sessionId) return reply(400, { error: 'missing-session' })
|
|
785
|
+
if (typeof sessions.applyConfig !== 'function') return reply(503, { error: 'config-unavailable' })
|
|
786
|
+
return reply(200, await sessions.applyConfig(sessionId, body))
|
|
301
787
|
}
|
|
302
788
|
if (pathname === '/peer/sessions' && req.method === 'GET') {
|
|
303
789
|
const value = await sessions.list()
|
|
304
|
-
return
|
|
790
|
+
return reply(200, { items: value.map((item) => ({ ...item, title: item.title })) })
|
|
305
791
|
}
|
|
306
792
|
if (pathname === '/peer/stream' && req.method === 'GET') {
|
|
307
793
|
return streamSession(req, res, url)
|
|
308
794
|
}
|
|
309
795
|
if (pathname === '/peer/prompt' && req.method === 'POST') {
|
|
310
|
-
const body = await readJson(req)
|
|
311
796
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
312
797
|
const text = typeof body?.text === 'string' ? body.text : ''
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
798
|
+
const attachments = Array.isArray(body?.attachments) ? body.attachments : []
|
|
799
|
+
if (!sessionId || (!text.trim() && attachments.length === 0)) return reply(400, { error: 'bad-request' })
|
|
800
|
+
const value = await sessions.prompt(sessionId, { text, attachments })
|
|
801
|
+
return reply(200, value)
|
|
316
802
|
}
|
|
317
803
|
if (pathname === '/peer/cancel' && req.method === 'POST') {
|
|
318
|
-
const body = await readJson(req)
|
|
319
804
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
320
|
-
if (!sessionId) return
|
|
321
|
-
return
|
|
805
|
+
if (!sessionId) return reply(400, { error: 'bad-request' })
|
|
806
|
+
return reply(200, sessions.cancel(sessionId))
|
|
322
807
|
}
|
|
323
|
-
return
|
|
808
|
+
return reply(404, { error: 'not-found' })
|
|
324
809
|
}
|
|
325
810
|
|
|
326
811
|
function verifyPeer(req, url) {
|
|
@@ -333,16 +818,37 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
333
818
|
if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
|
|
334
819
|
return { ok: false, error: 'stale-signature' }
|
|
335
820
|
}
|
|
821
|
+
// A pinned device key retires the shared secret: the approval response that
|
|
822
|
+
// carried that secret crossed the relay in plaintext, so accepting HMAC
|
|
823
|
+
// afterwards would let anyone who saw it forge calls.
|
|
824
|
+
if (approval.publicKey && String(req.headers['x-dsh-peer-alg'] || '') !== 'ed25519') {
|
|
825
|
+
return { ok: false, error: 'legacy-auth-disabled' }
|
|
826
|
+
}
|
|
827
|
+
if (String(req.headers['x-dsh-peer-alg'] || '') === 'ed25519') {
|
|
828
|
+
if (!approval.publicKey) return { ok: false, error: 'no-device-key' }
|
|
829
|
+
const nonce = String(req.headers['x-dsh-peer-nonce'] || '')
|
|
830
|
+
if (!nonce) return { ok: false, error: 'missing-nonce' }
|
|
831
|
+
const payload = `${deviceId}\n${req.method || 'GET'}\n${url.pathname + url.search}\n${timestamp}\n${nonce}`
|
|
832
|
+
if (!verifyPayload(approval.publicKey, payload, signature)) return { ok: false, error: 'bad-signature' }
|
|
833
|
+
if (!acceptNonce(deviceId, nonce)) return { ok: false, error: 'replayed' }
|
|
834
|
+
return { ok: true, deviceId, approval }
|
|
835
|
+
}
|
|
336
836
|
const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
|
|
337
837
|
const actual = Buffer.from(signature, 'hex')
|
|
338
838
|
const wanted = Buffer.from(expected, 'hex')
|
|
339
839
|
if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
|
|
340
|
-
return { ok: true }
|
|
840
|
+
return { ok: true, deviceId, approval }
|
|
341
841
|
}
|
|
342
842
|
|
|
343
843
|
async function streamSession(req, res, url) {
|
|
344
844
|
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
345
845
|
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
846
|
+
// Frames use their own key space, so a stream can never desync the unary
|
|
847
|
+
// body counters.
|
|
848
|
+
const peerDeviceId = String(req.headers['x-dsh-peer-id'] || '')
|
|
849
|
+
const streamPair = String(req.headers['x-dsh-lane'] || '') === 'sealed' && peerDeviceId
|
|
850
|
+
? lanCipherFor(peerDeviceId, 'lan-stream')
|
|
851
|
+
: undefined
|
|
346
852
|
const controller = new AbortController()
|
|
347
853
|
res.writeHead(200, {
|
|
348
854
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -366,7 +872,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
366
872
|
try {
|
|
367
873
|
for await (const frame of sessions.follow(sessionId, controller.signal)) {
|
|
368
874
|
if (res.writableEnded || res.destroyed) break
|
|
369
|
-
|
|
875
|
+
const payload = JSON.stringify(frame)
|
|
876
|
+
const wire = streamPair ? JSON.stringify({ enc: streamPair.seal(payload) }) : payload
|
|
877
|
+
res.write(`data: ${wire}\n\n`)
|
|
370
878
|
}
|
|
371
879
|
} catch (error) {
|
|
372
880
|
if (!controller.signal.aborted) {
|
|
@@ -385,24 +893,194 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
385
893
|
* One signed call to a peer over whichever transport reaches it: the relay
|
|
386
894
|
* when the peer was discovered there, direct HTTP for a LAN peer.
|
|
387
895
|
*/
|
|
388
|
-
|
|
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)
|
|
958
|
+
const bodySize = body === undefined ? 0 : JSON.stringify(body).length
|
|
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(
|
|
978
|
+
{ address: approval.punch.address, port: approval.punch.port },
|
|
979
|
+
deviceId,
|
|
980
|
+
{
|
|
981
|
+
method,
|
|
982
|
+
path: pathname,
|
|
983
|
+
headers: { ...auth, accept: 'application/json', 'content-type': 'application/json' },
|
|
984
|
+
bodyText: body === undefined ? '' : JSON.stringify(body),
|
|
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,
|
|
1006
|
+
)
|
|
1007
|
+
if (!result.error) {
|
|
1008
|
+
markLaneHealthy(deviceId, 'lan')
|
|
1009
|
+
return result
|
|
1010
|
+
}
|
|
1011
|
+
markLaneBlocked(deviceId, 'lan', result.error)
|
|
1012
|
+
return result
|
|
1013
|
+
}
|
|
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)
|
|
1021
|
+
const lane = pair
|
|
1022
|
+
? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
|
|
1023
|
+
: undefined
|
|
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')
|
|
389
1050
|
if (peer.proto !== 'relay') {
|
|
390
|
-
const direct = await
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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')
|
|
1056
|
+
}
|
|
1057
|
+
return finish(direct, 'lan')
|
|
396
1058
|
}
|
|
397
|
-
return
|
|
1059
|
+
return finish(await viaRelayLane(), 'relay')
|
|
398
1060
|
}
|
|
399
1061
|
|
|
400
1062
|
async function relayCall(peer, method, pathname, body, secret) {
|
|
401
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
|
+
}
|
|
1076
|
+
peer.lastTransport = 'relay'
|
|
402
1077
|
const callerId = self().deviceId
|
|
403
1078
|
const timestamp = String(Date.now())
|
|
404
1079
|
const headers = { accept: 'application/json' }
|
|
405
|
-
|
|
1080
|
+
const relayAuth = authHeaders(peer.deviceId, method, pathname)
|
|
1081
|
+
if (relayAuth) {
|
|
1082
|
+
Object.assign(headers, relayAuth)
|
|
1083
|
+
} else if (secret) {
|
|
406
1084
|
headers['x-dsh-peer-id'] = callerId
|
|
407
1085
|
headers['x-dsh-peer-ts'] = timestamp
|
|
408
1086
|
headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
|
|
@@ -424,13 +1102,13 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
424
1102
|
return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
|
|
425
1103
|
}
|
|
426
1104
|
|
|
427
|
-
async function callPeer(deviceId, method, pathname, body) {
|
|
1105
|
+
async function callPeer(deviceId, method, pathname, body, options) {
|
|
428
1106
|
syncRelayPeers()
|
|
429
1107
|
const peer = peers.get(deviceId)
|
|
430
1108
|
if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
|
|
431
1109
|
const approval = approvals.get(deviceId)
|
|
432
1110
|
if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
|
|
433
|
-
return peerCall(peer, method, pathname, body, approval.secret)
|
|
1111
|
+
return peerCall(peer, method, pathname, body, approval.secret, options)
|
|
434
1112
|
}
|
|
435
1113
|
|
|
436
1114
|
/** Relay one peer's SSE transcript to a local response (Devices panel). */
|
|
@@ -445,11 +1123,58 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
445
1123
|
const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
|
|
446
1124
|
const timestamp = String(Date.now())
|
|
447
1125
|
const callerId = self().deviceId
|
|
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')) {
|
|
1144
|
+
const auth = authHeaders(deviceId, 'GET', target) ?? {}
|
|
1145
|
+
res.writeHead(200, {
|
|
1146
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1147
|
+
'cache-control': 'no-store, no-transform',
|
|
1148
|
+
connection: 'keep-alive',
|
|
1149
|
+
'x-accel-buffering': 'no',
|
|
1150
|
+
})
|
|
1151
|
+
const handle = p2pNode.openStream(
|
|
1152
|
+
{ address: approval.punch.address, port: approval.punch.port },
|
|
1153
|
+
deviceId,
|
|
1154
|
+
{ path: target, headers: { ...auth, accept: 'text/event-stream' } },
|
|
1155
|
+
{
|
|
1156
|
+
onChunk: (chunk) => { try { res.write(chunk) } catch { /* client gone */ } },
|
|
1157
|
+
onEnd: () => { try { res.end() } catch { /* ignore */ } },
|
|
1158
|
+
},
|
|
1159
|
+
)
|
|
1160
|
+
if (!handle.ok) {
|
|
1161
|
+
markLaneBlocked(deviceId, 'p2p', 'stream-open-failed')
|
|
1162
|
+
try { res.destroy() } catch { /* ignore */ }
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
peer.lastTransport = 'p2p'
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
448
1168
|
if (peer.proto === 'relay') {
|
|
449
1169
|
if (!relay) {
|
|
450
1170
|
json(res, 503, { error: 'relay-offline' })
|
|
451
1171
|
return
|
|
452
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
|
+
}
|
|
453
1178
|
relay.stream(peer.deviceId, {
|
|
454
1179
|
path: target,
|
|
455
1180
|
headers: {
|
|
@@ -457,16 +1182,21 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
457
1182
|
'x-dsh-peer-id': callerId,
|
|
458
1183
|
'x-dsh-peer-ts': timestamp,
|
|
459
1184
|
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
1185
|
+
...(authHeaders(peer.deviceId, 'GET', target) ?? {}),
|
|
460
1186
|
},
|
|
461
1187
|
}, res)
|
|
462
1188
|
return
|
|
463
1189
|
}
|
|
1190
|
+
const streamPair = lanCipherFor(peer.deviceId, 'lan-stream')
|
|
1191
|
+
const streamAuth = authHeaders(peer.deviceId, 'GET', target)
|
|
464
1192
|
const streamHeaders = {
|
|
465
1193
|
accept: 'text/event-stream',
|
|
466
1194
|
'x-dsh-peer-id': callerId,
|
|
467
1195
|
'x-dsh-peer-ts': timestamp,
|
|
468
1196
|
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
1197
|
+
...(streamAuth ?? {}),
|
|
469
1198
|
}
|
|
1199
|
+
if (streamPair) streamHeaders['x-dsh-lane'] = 'sealed'
|
|
470
1200
|
const upstream = http.request({
|
|
471
1201
|
host: peer.address,
|
|
472
1202
|
port: peer.port,
|
|
@@ -474,25 +1204,67 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
474
1204
|
method: 'GET',
|
|
475
1205
|
headers: streamHeaders,
|
|
476
1206
|
}, (response) => {
|
|
1207
|
+
responded = true
|
|
1208
|
+
clearTimeout(connectTimer)
|
|
1209
|
+
markLaneHealthy(deviceId, 'lan')
|
|
1210
|
+
peer.lastTiming = { lane: 'lan', ms: Date.now() - started, at: Date.now() }
|
|
477
1211
|
res.writeHead(response.statusCode ?? 502, {
|
|
478
1212
|
'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
|
|
479
1213
|
'cache-control': 'no-store, no-transform',
|
|
480
1214
|
connection: 'keep-alive',
|
|
481
1215
|
'x-accel-buffering': 'no',
|
|
482
1216
|
})
|
|
483
|
-
|
|
1217
|
+
if (!streamPair) {
|
|
1218
|
+
response.pipe(res)
|
|
1219
|
+
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
// Decrypt each sealed frame before handing it to the local client.
|
|
1223
|
+
response.setEncoding('utf8')
|
|
1224
|
+
let buffer = ''
|
|
1225
|
+
response.on('data', (chunk) => {
|
|
1226
|
+
buffer += chunk
|
|
1227
|
+
let index
|
|
1228
|
+
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
1229
|
+
const event = buffer.slice(0, index)
|
|
1230
|
+
buffer = buffer.slice(index + 2)
|
|
1231
|
+
const line = event.split('\n').find((entry) => entry.startsWith('data: '))
|
|
1232
|
+
if (line) {
|
|
1233
|
+
try {
|
|
1234
|
+
const parsed = JSON.parse(line.slice(6))
|
|
1235
|
+
if (parsed && typeof parsed.enc === 'string') {
|
|
1236
|
+
res.write(`data: ${streamPair.open(parsed.enc).toString('utf8')}\n\n`)
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
} catch {
|
|
1240
|
+
continue
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
res.write(`${event}\n\n`)
|
|
1244
|
+
}
|
|
1245
|
+
})
|
|
484
1246
|
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
485
1247
|
})
|
|
486
|
-
|
|
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)
|
|
487
1255
|
// Same fallback as peerCall: a blocked LAN route may still reach the
|
|
488
1256
|
// peer through the outbound relay.
|
|
1257
|
+
if (!responded) markLaneBlocked(deviceId, 'lan', error?.message || 'stream-failed')
|
|
489
1258
|
if (relay && !res.headersSent) {
|
|
490
1259
|
relay.stream(peer.deviceId, { path: target, headers: streamHeaders }, res)
|
|
491
1260
|
return
|
|
492
1261
|
}
|
|
493
1262
|
try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
|
|
494
1263
|
})
|
|
495
|
-
res.on('close', () => {
|
|
1264
|
+
res.on('close', () => {
|
|
1265
|
+
clearTimeout(connectTimer)
|
|
1266
|
+
try { upstream.destroy() } catch { /* ignore */ }
|
|
1267
|
+
})
|
|
496
1268
|
upstream.end()
|
|
497
1269
|
}
|
|
498
1270
|
|
|
@@ -505,8 +1277,44 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
505
1277
|
callPeer,
|
|
506
1278
|
proxyStream,
|
|
507
1279
|
handlePeer,
|
|
1280
|
+
rehandshake: rehandshakePeer,
|
|
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
|
+
},
|
|
1306
|
+
async clearPunch(peerDeviceId) {
|
|
1307
|
+
const approval = approvals.get(peerDeviceId)
|
|
1308
|
+
if (!approval) return { ok: false, error: 'unknown-peer' }
|
|
1309
|
+
const next = { ...approval }
|
|
1310
|
+
delete next.punch
|
|
1311
|
+
approvals.set(peerDeviceId, next)
|
|
1312
|
+
await saveApprovals()
|
|
1313
|
+
return { ok: true }
|
|
1314
|
+
},
|
|
508
1315
|
setRelay(link) {
|
|
509
1316
|
relay = link
|
|
1317
|
+
if (link && typeof link.setSecurity === 'function') link.setSecurity(security)
|
|
510
1318
|
if (!link) {
|
|
511
1319
|
// Without a relay every relay-only peer is unreachable; keeping it
|
|
512
1320
|
// would make each call fail with a misleading 503.
|
|
@@ -515,6 +1323,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
515
1323
|
}
|
|
516
1324
|
}
|
|
517
1325
|
},
|
|
1326
|
+
setScopes(deviceId, scopes) {
|
|
1327
|
+
const approval = approvals.get(deviceId)
|
|
1328
|
+
if (!approval) return { ok: false, error: 'unknown-device' }
|
|
1329
|
+
const next = Array.isArray(scopes) ? scopes.filter((scope) => DEFAULT_SCOPES.includes(scope)) : []
|
|
1330
|
+
approvals.set(deviceId, { ...approval, scopes: next })
|
|
1331
|
+
saveApprovals().catch(() => {})
|
|
1332
|
+
return { ok: true, scopes: next }
|
|
1333
|
+
},
|
|
1334
|
+
revoke(deviceId) {
|
|
1335
|
+
if (!approvals.delete(deviceId)) return { ok: false, error: 'unknown-device' }
|
|
1336
|
+
ciphers.delete(deviceId)
|
|
1337
|
+
saveApprovals().catch(() => {})
|
|
1338
|
+
return { ok: true }
|
|
1339
|
+
},
|
|
518
1340
|
|
|
519
1341
|
snapshot() {
|
|
520
1342
|
syncRelayPeers()
|
|
@@ -531,6 +1353,19 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
531
1353
|
approved: approvals.has(peer.deviceId),
|
|
532
1354
|
connecting: outgoing.has(peer.deviceId),
|
|
533
1355
|
transport: peer.proto === 'relay' ? 'relay' : 'lan',
|
|
1356
|
+
relay: approvals.get(peer.deviceId)?.relay ?? '',
|
|
1357
|
+
fingerprint: approvals.get(peer.deviceId)?.fingerprint ?? '',
|
|
1358
|
+
forwardSecrecy: Boolean(approvals.get(peer.deviceId)?.peerEphemeral && ephemerals.has(peer.deviceId)),
|
|
1359
|
+
lane: selectLane({ sameLan: peer.proto !== 'relay', punched: Boolean(approvals.get(peer.deviceId)?.punch), relayAvailable: Boolean(relay) }),
|
|
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),
|
|
1364
|
+
punch: approvals.get(peer.deviceId)?.punch ?? null,
|
|
1365
|
+
candidates: Array.isArray(approvals.get(peer.deviceId)?.candidates) ? approvals.get(peer.deviceId).candidates.length : 0,
|
|
1366
|
+
punchPort: peer.punchPort ?? 0,
|
|
1367
|
+
sas: approvals.get(peer.deviceId)?.sas ?? '',
|
|
1368
|
+
scopes: approvals.get(peer.deviceId)?.scopes ?? [],
|
|
534
1369
|
})),
|
|
535
1370
|
requests: [...requests.values()]
|
|
536
1371
|
.filter((request) => request.status === 'pending')
|
|
@@ -540,6 +1375,11 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
540
1375
|
deviceName: request.deviceName,
|
|
541
1376
|
userId: request.userId,
|
|
542
1377
|
at: request.at,
|
|
1378
|
+
relay: request.relay ?? '',
|
|
1379
|
+
pairCode: request.pairCode ?? '',
|
|
1380
|
+
sas: request.sas ?? '',
|
|
1381
|
+
fingerprint: request.fingerprint ?? '',
|
|
1382
|
+
relayChanged: Boolean(request.relayChanged),
|
|
543
1383
|
})),
|
|
544
1384
|
}
|
|
545
1385
|
},
|
|
@@ -554,15 +1394,21 @@ function sign(secret, deviceId, method, pathname, timestamp, body) {
|
|
|
554
1394
|
.digest('hex')
|
|
555
1395
|
}
|
|
556
1396
|
|
|
557
|
-
function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
1397
|
+
function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane, timeoutMs = 15000) {
|
|
558
1398
|
return new Promise((resolve) => {
|
|
559
|
-
|
|
1399
|
+
let text = body === undefined ? '' : JSON.stringify(body)
|
|
1400
|
+
if (lane && body !== undefined) {
|
|
1401
|
+
try { text = JSON.stringify({ enc: lane.seal(text) }) } catch { /* keep plaintext */ }
|
|
1402
|
+
}
|
|
560
1403
|
const headers = { accept: 'application/json' }
|
|
561
1404
|
if (text) {
|
|
562
1405
|
headers['content-type'] = 'application/json'
|
|
563
1406
|
headers['content-length'] = Buffer.byteLength(text)
|
|
564
1407
|
}
|
|
565
|
-
if (
|
|
1408
|
+
if (extraHeaders) {
|
|
1409
|
+
Object.assign(headers, extraHeaders)
|
|
1410
|
+
if (lane) headers['x-dsh-lane'] = 'sealed'
|
|
1411
|
+
} else if (secret && callerId) {
|
|
566
1412
|
const timestamp = String(Date.now())
|
|
567
1413
|
headers['x-dsh-peer-id'] = callerId
|
|
568
1414
|
headers['x-dsh-peer-ts'] = timestamp
|
|
@@ -576,13 +1422,16 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
|
576
1422
|
path: pathname,
|
|
577
1423
|
method,
|
|
578
1424
|
headers,
|
|
579
|
-
timeout:
|
|
1425
|
+
timeout: timeoutMs,
|
|
580
1426
|
}, (res) => {
|
|
581
1427
|
let data = ''
|
|
582
1428
|
res.on('data', (chunk) => { data += chunk })
|
|
583
1429
|
res.on('end', () => {
|
|
584
1430
|
let parsed
|
|
585
1431
|
try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
|
|
1432
|
+
if (lane && parsed && typeof parsed.enc === 'string') {
|
|
1433
|
+
try { parsed = JSON.parse(lane.open(parsed.enc)) } catch { parsed = { error: 'bad-envelope' } }
|
|
1434
|
+
}
|
|
586
1435
|
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
|
|
587
1436
|
})
|
|
588
1437
|
})
|
|
@@ -592,13 +1441,19 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
|
592
1441
|
})
|
|
593
1442
|
}
|
|
594
1443
|
|
|
595
|
-
function readJson(req) {
|
|
1444
|
+
function readJson(req, maxBytes = 1024 * 1024) {
|
|
596
1445
|
return new Promise((resolve, reject) => {
|
|
1446
|
+
const declared = Number(req.headers?.['content-length'])
|
|
1447
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
1448
|
+
reject(new Error('too large'))
|
|
1449
|
+
req.destroy()
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
597
1452
|
const chunks = []
|
|
598
1453
|
let size = 0
|
|
599
1454
|
req.on('data', (chunk) => {
|
|
600
1455
|
size += chunk.length
|
|
601
|
-
if (size >
|
|
1456
|
+
if (size > maxBytes) { reject(new Error('too large')); req.destroy(); return }
|
|
602
1457
|
chunks.push(chunk)
|
|
603
1458
|
})
|
|
604
1459
|
req.on('end', () => {
|
|
@@ -619,6 +1474,25 @@ function json(res, status, value) {
|
|
|
619
1474
|
res.end(body)
|
|
620
1475
|
}
|
|
621
1476
|
|
|
1477
|
+
function createFileStore(file, log) {
|
|
1478
|
+
return {
|
|
1479
|
+
async load() {
|
|
1480
|
+
try { return JSON.parse(await readFile(file, 'utf8')) } catch (error) {
|
|
1481
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
|
|
1482
|
+
return null
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
async save(value) {
|
|
1486
|
+
try {
|
|
1487
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
1488
|
+
await writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
|
|
1491
|
+
}
|
|
1492
|
+
},
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
622
1496
|
function delay(ms) {
|
|
623
1497
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
624
1498
|
}
|