dsh-remote-control-plugin 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- 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 +170 -0
- package/index.js +205 -28
- 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
|
}
|
|
@@ -150,13 +479,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
150
479
|
if (loaded) return
|
|
151
480
|
loaded = true
|
|
152
481
|
try {
|
|
153
|
-
const raw =
|
|
482
|
+
const raw = await approvalStore.load()
|
|
154
483
|
for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
|
|
155
484
|
if (entry && typeof entry.secret === 'string') {
|
|
156
485
|
approvals.set(deviceId, {
|
|
157
486
|
deviceName: entry.deviceName ?? 'DSH device',
|
|
158
487
|
secret: entry.secret,
|
|
159
488
|
approvedAt: entry.approvedAt ?? Date.now(),
|
|
489
|
+
relay: entry.relay ?? '',
|
|
490
|
+
publicKey: entry.publicKey ?? '',
|
|
491
|
+
fingerprint: entry.fingerprint ?? '',
|
|
492
|
+
agreementKey: entry.agreementKey ?? '',
|
|
493
|
+
peerEphemeral: entry.peerEphemeral ?? '',
|
|
494
|
+
sas: entry.sas ?? '',
|
|
495
|
+
scopes: Array.isArray(entry.scopes) ? entry.scopes : [...DEFAULT_SCOPES],
|
|
160
496
|
})
|
|
161
497
|
}
|
|
162
498
|
}
|
|
@@ -169,10 +505,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
169
505
|
const peersObject = {}
|
|
170
506
|
for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
|
|
171
507
|
try {
|
|
172
|
-
await
|
|
173
|
-
await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
|
|
508
|
+
await approvalStore.save({ version: 1, peers: peersObject })
|
|
174
509
|
} catch (error) {
|
|
175
|
-
log.warn(`[remote-control] could not persist
|
|
510
|
+
log.warn(`[remote-control] could not persist approvals: ${error?.message || error}`)
|
|
176
511
|
}
|
|
177
512
|
}
|
|
178
513
|
|
|
@@ -184,11 +519,38 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
184
519
|
if (!deviceId) return json(res, 400, { error: 'missing-device' })
|
|
185
520
|
if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
|
|
186
521
|
const requestId = randomBytes(16).toString('hex')
|
|
522
|
+
const claimedRelay = typeof body?.relay === 'string' ? body.relay : ''
|
|
523
|
+
const claimedPairCode = typeof body?.pairCode === 'string' ? body.pairCode : ''
|
|
524
|
+
const claimedFingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : ''
|
|
525
|
+
const claimedKey = typeof body?.publicKey === 'string' ? body.publicKey : ''
|
|
526
|
+
const known = approvals.get(deviceId)
|
|
527
|
+
const who = self()
|
|
528
|
+
const claimedEphemeral = body?.ephemeral
|
|
529
|
+
const ephemeralOk = verifyEphemeral(claimedKey, deviceId, claimedPairCode, claimedEphemeral)
|
|
530
|
+
const replyEphemeral = mintFor(deviceId, claimedPairCode)
|
|
187
531
|
requests.set(requestId, {
|
|
188
532
|
requestId,
|
|
189
533
|
deviceId,
|
|
190
534
|
deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
|
|
191
535
|
userId: typeof body.userId === 'string' ? body.userId : null,
|
|
536
|
+
relay: claimedRelay,
|
|
537
|
+
pairCode: claimedPairCode,
|
|
538
|
+
publicKey: claimedKey,
|
|
539
|
+
fingerprint: claimedFingerprint,
|
|
540
|
+
peerEphemeral: ephemeralOk ? claimedEphemeral.publicPem : '',
|
|
541
|
+
candidates: Array.isArray(body?.candidates) ? body.candidates.filter((entry) => typeof entry === 'string').slice(0, 16) : [],
|
|
542
|
+
punchPort: Number.isInteger(body?.punchPort) ? body.punchPort : 0,
|
|
543
|
+
publicEndpoint: body?.publicEndpoint && typeof body.publicEndpoint.address === 'string' && Number.isInteger(body.publicEndpoint.port)
|
|
544
|
+
? { address: body.publicEndpoint.address, port: body.publicEndpoint.port }
|
|
545
|
+
: null,
|
|
546
|
+
replyEphemeral,
|
|
547
|
+
agreementKey: typeof body?.agreementKey === 'string' ? body.agreementKey : '',
|
|
548
|
+
// Both screens show these digits only when both keys and the invite
|
|
549
|
+
// nonce agree; a mismatch is a hard stop, not a warning.
|
|
550
|
+
sas: claimedFingerprint && who.fingerprint
|
|
551
|
+
? sasCode(who.fingerprint, claimedFingerprint, claimedPairCode)
|
|
552
|
+
: '',
|
|
553
|
+
relayChanged: Boolean(known?.relay && claimedRelay && known.relay !== claimedRelay),
|
|
192
554
|
address: req.socket?.remoteAddress ?? '',
|
|
193
555
|
at: Date.now(),
|
|
194
556
|
status: 'pending',
|
|
@@ -201,10 +563,26 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
201
563
|
function helloStatus(res, requestId) {
|
|
202
564
|
const request = requests.get(requestId)
|
|
203
565
|
if (!request) return json(res, 404, { error: 'unknown-request' })
|
|
566
|
+
const who = self()
|
|
204
567
|
if (request.status === 'approved') {
|
|
205
|
-
return json(res, 200, {
|
|
568
|
+
return json(res, 200, {
|
|
569
|
+
status: 'approved',
|
|
570
|
+
// A peer that pinned a device key never needs the shared secret, and
|
|
571
|
+
// the relay would see this response in plaintext. Only legacy peers
|
|
572
|
+
// (no claimed public key) still receive one.
|
|
573
|
+
...(request.publicKey ? {} : { secret: request.secret }),
|
|
574
|
+
deviceName: who.deviceName,
|
|
575
|
+
publicKey: who.publicKey ?? '',
|
|
576
|
+
fingerprint: who.fingerprint ?? '',
|
|
577
|
+
agreementKey: who.agreementKey ?? '',
|
|
578
|
+
ephemeral: request.replyEphemeral,
|
|
579
|
+
candidates: localCandidates().map((entry) => entry.address),
|
|
580
|
+
punchPort: config.punchPort ?? 0,
|
|
581
|
+
publicEndpoint: publicEndpoint ?? null,
|
|
582
|
+
sas: request.sas ?? '',
|
|
583
|
+
})
|
|
206
584
|
}
|
|
207
|
-
return json(res, 200, { status: request.status })
|
|
585
|
+
return json(res, 200, { status: request.status, sas: request.sas ?? '' })
|
|
208
586
|
}
|
|
209
587
|
|
|
210
588
|
async function approveRequest(requestId) {
|
|
@@ -213,7 +591,22 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
213
591
|
const secret = randomBytes(32).toString('hex')
|
|
214
592
|
request.status = 'approved'
|
|
215
593
|
request.secret = secret
|
|
216
|
-
approvals.set(request.deviceId, {
|
|
594
|
+
approvals.set(request.deviceId, {
|
|
595
|
+
deviceName: request.deviceName,
|
|
596
|
+
secret,
|
|
597
|
+
approvedAt: Date.now(),
|
|
598
|
+
relay: request.relay ?? '',
|
|
599
|
+
publicKey: request.publicKey ?? '',
|
|
600
|
+
fingerprint: request.fingerprint ?? '',
|
|
601
|
+
agreementKey: request.agreementKey ?? '',
|
|
602
|
+
peerEphemeral: request.peerEphemeral ?? '',
|
|
603
|
+
candidates: Array.isArray(request.candidates) ? request.candidates : [],
|
|
604
|
+
punchPort: Number.isInteger(request.punchPort) ? request.punchPort : 0,
|
|
605
|
+
publicEndpoint: request.publicEndpoint ?? null,
|
|
606
|
+
secret: request.publicKey ? '' : request.secret,
|
|
607
|
+
sas: request.sas ?? '',
|
|
608
|
+
scopes: [...DEFAULT_SCOPES],
|
|
609
|
+
})
|
|
217
610
|
await saveApprovals()
|
|
218
611
|
const peer = peers.get(request.deviceId)
|
|
219
612
|
if (peer) { peer.approved = true; peer.secret = secret }
|
|
@@ -241,6 +634,15 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
241
634
|
deviceId: who.deviceId,
|
|
242
635
|
deviceName: who.deviceName,
|
|
243
636
|
userId: who.userId,
|
|
637
|
+
relay: who.relayUrl ?? '',
|
|
638
|
+
pairCode: who.pairCode ?? '',
|
|
639
|
+
publicKey: who.publicKey ?? '',
|
|
640
|
+
fingerprint: who.fingerprint ?? '',
|
|
641
|
+
agreementKey: who.agreementKey ?? '',
|
|
642
|
+
ephemeral: mintFor(peer.deviceId, who.pairCode ?? ''),
|
|
643
|
+
candidates: localCandidates().map((entry) => entry.address),
|
|
644
|
+
punchPort: config.punchPort ?? 0,
|
|
645
|
+
publicEndpoint: publicEndpoint ?? null,
|
|
244
646
|
})
|
|
245
647
|
if (!reply.ok || typeof reply.json?.requestId !== 'string') {
|
|
246
648
|
return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
|
|
@@ -258,11 +660,29 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
258
660
|
if (!outgoing.has(peer.deviceId)) return
|
|
259
661
|
const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
|
|
260
662
|
const status = reply.json?.status
|
|
261
|
-
if (status === 'approved'
|
|
262
|
-
|
|
663
|
+
if (status === 'approved') {
|
|
664
|
+
const replyEphemeral = reply.json.ephemeral
|
|
665
|
+
const ephemeralOk = verifyEphemeral(reply.json.publicKey ?? '', peer.deviceId, self().pairCode ?? '', replyEphemeral)
|
|
666
|
+
approvals.set(peer.deviceId, {
|
|
667
|
+
deviceName: peer.deviceName,
|
|
668
|
+
secret: reply.json.secret ?? '',
|
|
669
|
+
approvedAt: Date.now(),
|
|
670
|
+
relay: relay?.url() ?? '',
|
|
671
|
+
publicKey: reply.json.publicKey ?? '',
|
|
672
|
+
fingerprint: reply.json.fingerprint ?? '',
|
|
673
|
+
agreementKey: reply.json.agreementKey ?? '',
|
|
674
|
+
peerEphemeral: ephemeralOk ? replyEphemeral.publicPem : '',
|
|
675
|
+
candidates: Array.isArray(reply.json.candidates) ? reply.json.candidates : [],
|
|
676
|
+
punchPort: Number.isInteger(reply.json.punchPort) ? reply.json.punchPort : 0,
|
|
677
|
+
publicEndpoint: reply.json.publicEndpoint ?? null,
|
|
678
|
+
sas: reply.json.sas ?? '',
|
|
679
|
+
scopes: [...DEFAULT_SCOPES],
|
|
680
|
+
})
|
|
681
|
+
peer.publicKey = reply.json.publicKey ?? ''
|
|
682
|
+
peer.sas = reply.json.sas ?? ''
|
|
263
683
|
await saveApprovals()
|
|
264
684
|
peer.approved = true
|
|
265
|
-
peer.secret = reply.json.secret
|
|
685
|
+
peer.secret = reply.json.secret ?? ''
|
|
266
686
|
outgoing.delete(peer.deviceId)
|
|
267
687
|
log.info(`[remote-control] ${peer.deviceName} approved this device`)
|
|
268
688
|
return
|
|
@@ -285,42 +705,105 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
285
705
|
|
|
286
706
|
const verdict = verifyPeer(req, url)
|
|
287
707
|
if (!verdict.ok) return json(res, 403, { error: verdict.error })
|
|
708
|
+
if (!allowCall(verdict.deviceId)) return json(res, 429, { error: 'rate-limited' })
|
|
709
|
+
const requiredScope = scopeFor(req.method || 'GET', pathname)
|
|
710
|
+
if (requiredScope && Array.isArray(verdict.approval?.scopes) && !verdict.approval.scopes.includes(requiredScope)) {
|
|
711
|
+
return json(res, 403, { error: 'scope-denied', scope: requiredScope })
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const viaHeader = String(req.headers['x-dsh-via'] || '')
|
|
715
|
+
const fromRelay = viaHeader === 'relay' || viaHeader === 'p2p'
|
|
716
|
+
// A pinned peer on the direct transport must use the sealed lane; relay
|
|
717
|
+
// traffic is already inside the relay AEAD envelope.
|
|
718
|
+
let lanePair
|
|
719
|
+
if (!fromRelay && verdict.approval?.publicKey && pathname !== '/peer/stream') {
|
|
720
|
+
if (String(req.headers['x-dsh-lane'] || '') !== 'sealed') {
|
|
721
|
+
return json(res, 403, { error: 'plaintext-not-allowed' })
|
|
722
|
+
}
|
|
723
|
+
lanePair = lanCipherFor(verdict.deviceId)
|
|
724
|
+
if (!lanePair) return json(res, 403, { error: 'no-channel' })
|
|
725
|
+
}
|
|
726
|
+
// One body read for every authenticated POST: the peer may have sealed it
|
|
727
|
+
// with the LAN channel, and every route needs the same decoded value.
|
|
728
|
+
let body = {}
|
|
729
|
+
if (req.method === 'POST') {
|
|
730
|
+
let incoming
|
|
731
|
+
try {
|
|
732
|
+
incoming = await readJson(req, UPLOAD_BODY_BYTES)
|
|
733
|
+
} catch {
|
|
734
|
+
return json(res, 400, { error: 'bad-body' })
|
|
735
|
+
}
|
|
736
|
+
if (incoming && typeof incoming === 'object' && typeof incoming.enc === 'string') {
|
|
737
|
+
if (!lanePair) return json(res, 403, { error: 'no-channel' })
|
|
738
|
+
try {
|
|
739
|
+
body = JSON.parse(lanePair.open(incoming.enc).toString('utf8'))
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return json(res, 403, { error: 'bad-envelope' })
|
|
742
|
+
}
|
|
743
|
+
} else {
|
|
744
|
+
body = incoming ?? {}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const reply = lanePair
|
|
748
|
+
? (status, value) => json(res, status, { enc: lanePair.seal(JSON.stringify(value)) })
|
|
749
|
+
: (status, value) => json(res, status, value)
|
|
288
750
|
|
|
751
|
+
if (pathname.startsWith('/peer/upload/') && uploads) {
|
|
752
|
+
const action = pathname.slice('/peer/upload/'.length)
|
|
753
|
+
const handler = uploads[action]
|
|
754
|
+
if (typeof handler !== 'function') return reply(404, { error: 'not-found' })
|
|
755
|
+
const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams.entries()) : body
|
|
756
|
+
const result = await handler(payload)
|
|
757
|
+
return reply(result.status ?? 200, result)
|
|
758
|
+
}
|
|
759
|
+
if (pathname === '/peer/rehandshake' && req.method === 'POST') {
|
|
760
|
+
const peerMessage = body?.ephemeral
|
|
761
|
+
const mine = approvals.get(verdict.deviceId)
|
|
762
|
+
if (!peerMessage || !verifyEphemeral(mine?.publicKey ?? '', verdict.deviceId, self().pairCode ?? '', peerMessage)) {
|
|
763
|
+
return reply(400, { error: 'bad-handshake' })
|
|
764
|
+
}
|
|
765
|
+
const replyMessage = mintFor(verdict.deviceId, self().pairCode ?? '')
|
|
766
|
+
approvals.set(verdict.deviceId, { ...mine, peerEphemeral: peerMessage.publicPem })
|
|
767
|
+
ciphers.delete(verdict.deviceId)
|
|
768
|
+
for (const key of [...lanCiphers.keys()]) {
|
|
769
|
+
if (key.endsWith(`:${verdict.deviceId}`)) lanCiphers.delete(key)
|
|
770
|
+
}
|
|
771
|
+
await saveApprovals()
|
|
772
|
+
return reply(200, { ok: true, ephemeral: replyMessage })
|
|
773
|
+
}
|
|
289
774
|
if (pathname === '/peer/session/config' && req.method === 'GET') {
|
|
290
775
|
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
291
|
-
if (!sessionId) return
|
|
292
|
-
if (typeof sessions.config !== 'function') return
|
|
293
|
-
return
|
|
776
|
+
if (!sessionId) return reply(400, { error: 'missing-session' })
|
|
777
|
+
if (typeof sessions.config !== 'function') return reply(503, { error: 'config-unavailable' })
|
|
778
|
+
return reply(200, await sessions.config(sessionId))
|
|
294
779
|
}
|
|
295
780
|
if (pathname === '/peer/session/config' && req.method === 'POST') {
|
|
296
|
-
const body = await readJson(req)
|
|
297
781
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
298
|
-
if (!sessionId) return
|
|
299
|
-
if (typeof sessions.applyConfig !== 'function') return
|
|
300
|
-
return
|
|
782
|
+
if (!sessionId) return reply(400, { error: 'missing-session' })
|
|
783
|
+
if (typeof sessions.applyConfig !== 'function') return reply(503, { error: 'config-unavailable' })
|
|
784
|
+
return reply(200, await sessions.applyConfig(sessionId, body))
|
|
301
785
|
}
|
|
302
786
|
if (pathname === '/peer/sessions' && req.method === 'GET') {
|
|
303
787
|
const value = await sessions.list()
|
|
304
|
-
return
|
|
788
|
+
return reply(200, { items: value.map((item) => ({ ...item, title: item.title })) })
|
|
305
789
|
}
|
|
306
790
|
if (pathname === '/peer/stream' && req.method === 'GET') {
|
|
307
791
|
return streamSession(req, res, url)
|
|
308
792
|
}
|
|
309
793
|
if (pathname === '/peer/prompt' && req.method === 'POST') {
|
|
310
|
-
const body = await readJson(req)
|
|
311
794
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
312
795
|
const text = typeof body?.text === 'string' ? body.text : ''
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
796
|
+
const attachments = Array.isArray(body?.attachments) ? body.attachments : []
|
|
797
|
+
if (!sessionId || (!text.trim() && attachments.length === 0)) return reply(400, { error: 'bad-request' })
|
|
798
|
+
const value = await sessions.prompt(sessionId, { text, attachments })
|
|
799
|
+
return reply(200, value)
|
|
316
800
|
}
|
|
317
801
|
if (pathname === '/peer/cancel' && req.method === 'POST') {
|
|
318
|
-
const body = await readJson(req)
|
|
319
802
|
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
320
|
-
if (!sessionId) return
|
|
321
|
-
return
|
|
803
|
+
if (!sessionId) return reply(400, { error: 'bad-request' })
|
|
804
|
+
return reply(200, sessions.cancel(sessionId))
|
|
322
805
|
}
|
|
323
|
-
return
|
|
806
|
+
return reply(404, { error: 'not-found' })
|
|
324
807
|
}
|
|
325
808
|
|
|
326
809
|
function verifyPeer(req, url) {
|
|
@@ -333,16 +816,37 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
333
816
|
if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
|
|
334
817
|
return { ok: false, error: 'stale-signature' }
|
|
335
818
|
}
|
|
819
|
+
// A pinned device key retires the shared secret: the approval response that
|
|
820
|
+
// carried that secret crossed the relay in plaintext, so accepting HMAC
|
|
821
|
+
// afterwards would let anyone who saw it forge calls.
|
|
822
|
+
if (approval.publicKey && String(req.headers['x-dsh-peer-alg'] || '') !== 'ed25519') {
|
|
823
|
+
return { ok: false, error: 'legacy-auth-disabled' }
|
|
824
|
+
}
|
|
825
|
+
if (String(req.headers['x-dsh-peer-alg'] || '') === 'ed25519') {
|
|
826
|
+
if (!approval.publicKey) return { ok: false, error: 'no-device-key' }
|
|
827
|
+
const nonce = String(req.headers['x-dsh-peer-nonce'] || '')
|
|
828
|
+
if (!nonce) return { ok: false, error: 'missing-nonce' }
|
|
829
|
+
const payload = `${deviceId}\n${req.method || 'GET'}\n${url.pathname + url.search}\n${timestamp}\n${nonce}`
|
|
830
|
+
if (!verifyPayload(approval.publicKey, payload, signature)) return { ok: false, error: 'bad-signature' }
|
|
831
|
+
if (!acceptNonce(deviceId, nonce)) return { ok: false, error: 'replayed' }
|
|
832
|
+
return { ok: true, deviceId, approval }
|
|
833
|
+
}
|
|
336
834
|
const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
|
|
337
835
|
const actual = Buffer.from(signature, 'hex')
|
|
338
836
|
const wanted = Buffer.from(expected, 'hex')
|
|
339
837
|
if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
|
|
340
|
-
return { ok: true }
|
|
838
|
+
return { ok: true, deviceId, approval }
|
|
341
839
|
}
|
|
342
840
|
|
|
343
841
|
async function streamSession(req, res, url) {
|
|
344
842
|
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
345
843
|
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
844
|
+
// Frames use their own key space, so a stream can never desync the unary
|
|
845
|
+
// body counters.
|
|
846
|
+
const peerDeviceId = String(req.headers['x-dsh-peer-id'] || '')
|
|
847
|
+
const streamPair = String(req.headers['x-dsh-lane'] || '') === 'sealed' && peerDeviceId
|
|
848
|
+
? lanCipherFor(peerDeviceId, 'lan-stream')
|
|
849
|
+
: undefined
|
|
346
850
|
const controller = new AbortController()
|
|
347
851
|
res.writeHead(200, {
|
|
348
852
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -366,7 +870,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
366
870
|
try {
|
|
367
871
|
for await (const frame of sessions.follow(sessionId, controller.signal)) {
|
|
368
872
|
if (res.writableEnded || res.destroyed) break
|
|
369
|
-
|
|
873
|
+
const payload = JSON.stringify(frame)
|
|
874
|
+
const wire = streamPair ? JSON.stringify({ enc: streamPair.seal(payload) }) : payload
|
|
875
|
+
res.write(`data: ${wire}\n\n`)
|
|
370
876
|
}
|
|
371
877
|
} catch (error) {
|
|
372
878
|
if (!controller.signal.aborted) {
|
|
@@ -386,11 +892,40 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
386
892
|
* when the peer was discovered there, direct HTTP for a LAN peer.
|
|
387
893
|
*/
|
|
388
894
|
async function peerCall(peer, method, pathname, body, secret) {
|
|
895
|
+
const approval = approvals.get(peer.deviceId)
|
|
896
|
+
const datagramLimit = 64 * 1024
|
|
897
|
+
const bodySize = body === undefined ? 0 : JSON.stringify(body).length
|
|
898
|
+
if (p2pNode && approval?.punch && bodySize <= datagramLimit) {
|
|
899
|
+
const auth = authHeaders(peer.deviceId, method, pathname) ?? {}
|
|
900
|
+
const result = await p2pNode.request(
|
|
901
|
+
{ address: approval.punch.address, port: approval.punch.port },
|
|
902
|
+
peer.deviceId,
|
|
903
|
+
{
|
|
904
|
+
method,
|
|
905
|
+
path: pathname,
|
|
906
|
+
headers: { ...auth, accept: 'application/json', 'content-type': 'application/json' },
|
|
907
|
+
bodyText: body === undefined ? '' : JSON.stringify(body),
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
if (result.ok) {
|
|
911
|
+
peer.lastTransport = 'p2p'
|
|
912
|
+
let json = {}
|
|
913
|
+
try { json = result.message.bodyText ? JSON.parse(result.message.bodyText) : {} } catch { json = {} }
|
|
914
|
+
return { ok: result.message.status >= 200 && result.message.status < 300, status: result.message.status, json }
|
|
915
|
+
}
|
|
916
|
+
// A failed datagram path falls through to LAN and then the relay.
|
|
917
|
+
}
|
|
389
918
|
if (peer.proto !== 'relay') {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
919
|
+
const auth = authHeaders(peer.deviceId, method, pathname)
|
|
920
|
+
const pair = lanCipherFor(peer.deviceId)
|
|
921
|
+
const lane = pair
|
|
922
|
+
? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
|
|
923
|
+
: undefined
|
|
924
|
+
const direct = await callRaw(peer, method, pathname, body, secret, self().deviceId, auth, lane)
|
|
925
|
+
if (!direct.error) {
|
|
926
|
+
peer.lastTransport = 'lan'
|
|
927
|
+
return direct
|
|
928
|
+
}
|
|
394
929
|
const viaRelay = await relayCall(peer, method, pathname, body, secret)
|
|
395
930
|
return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
|
|
396
931
|
}
|
|
@@ -399,10 +934,14 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
399
934
|
|
|
400
935
|
async function relayCall(peer, method, pathname, body, secret) {
|
|
401
936
|
if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
|
|
937
|
+
peer.lastTransport = 'relay'
|
|
402
938
|
const callerId = self().deviceId
|
|
403
939
|
const timestamp = String(Date.now())
|
|
404
940
|
const headers = { accept: 'application/json' }
|
|
405
|
-
|
|
941
|
+
const relayAuth = authHeaders(peer.deviceId, method, pathname)
|
|
942
|
+
if (relayAuth) {
|
|
943
|
+
Object.assign(headers, relayAuth)
|
|
944
|
+
} else if (secret) {
|
|
406
945
|
headers['x-dsh-peer-id'] = callerId
|
|
407
946
|
headers['x-dsh-peer-ts'] = timestamp
|
|
408
947
|
headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
|
|
@@ -445,6 +984,30 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
445
984
|
const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
|
|
446
985
|
const timestamp = String(Date.now())
|
|
447
986
|
const callerId = self().deviceId
|
|
987
|
+
if (config.p2pStreams !== false && p2pNode && approval.punch) {
|
|
988
|
+
const auth = authHeaders(deviceId, 'GET', target) ?? {}
|
|
989
|
+
res.writeHead(200, {
|
|
990
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
991
|
+
'cache-control': 'no-store, no-transform',
|
|
992
|
+
connection: 'keep-alive',
|
|
993
|
+
'x-accel-buffering': 'no',
|
|
994
|
+
})
|
|
995
|
+
const handle = p2pNode.openStream(
|
|
996
|
+
{ address: approval.punch.address, port: approval.punch.port },
|
|
997
|
+
deviceId,
|
|
998
|
+
{ path: target, headers: { ...auth, accept: 'text/event-stream' } },
|
|
999
|
+
{
|
|
1000
|
+
onChunk: (chunk) => { try { res.write(chunk) } catch { /* client gone */ } },
|
|
1001
|
+
onEnd: () => { try { res.end() } catch { /* ignore */ } },
|
|
1002
|
+
},
|
|
1003
|
+
)
|
|
1004
|
+
if (!handle.ok) {
|
|
1005
|
+
try { res.destroy() } catch { /* ignore */ }
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
peer.lastTransport = 'p2p'
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
448
1011
|
if (peer.proto === 'relay') {
|
|
449
1012
|
if (!relay) {
|
|
450
1013
|
json(res, 503, { error: 'relay-offline' })
|
|
@@ -457,16 +1020,21 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
457
1020
|
'x-dsh-peer-id': callerId,
|
|
458
1021
|
'x-dsh-peer-ts': timestamp,
|
|
459
1022
|
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
1023
|
+
...(authHeaders(peer.deviceId, 'GET', target) ?? {}),
|
|
460
1024
|
},
|
|
461
1025
|
}, res)
|
|
462
1026
|
return
|
|
463
1027
|
}
|
|
1028
|
+
const streamPair = lanCipherFor(peer.deviceId, 'lan-stream')
|
|
1029
|
+
const streamAuth = authHeaders(peer.deviceId, 'GET', target)
|
|
464
1030
|
const streamHeaders = {
|
|
465
1031
|
accept: 'text/event-stream',
|
|
466
1032
|
'x-dsh-peer-id': callerId,
|
|
467
1033
|
'x-dsh-peer-ts': timestamp,
|
|
468
1034
|
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
1035
|
+
...(streamAuth ?? {}),
|
|
469
1036
|
}
|
|
1037
|
+
if (streamPair) streamHeaders['x-dsh-lane'] = 'sealed'
|
|
470
1038
|
const upstream = http.request({
|
|
471
1039
|
host: peer.address,
|
|
472
1040
|
port: peer.port,
|
|
@@ -480,7 +1048,35 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
480
1048
|
connection: 'keep-alive',
|
|
481
1049
|
'x-accel-buffering': 'no',
|
|
482
1050
|
})
|
|
483
|
-
|
|
1051
|
+
if (!streamPair) {
|
|
1052
|
+
response.pipe(res)
|
|
1053
|
+
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
// Decrypt each sealed frame before handing it to the local client.
|
|
1057
|
+
response.setEncoding('utf8')
|
|
1058
|
+
let buffer = ''
|
|
1059
|
+
response.on('data', (chunk) => {
|
|
1060
|
+
buffer += chunk
|
|
1061
|
+
let index
|
|
1062
|
+
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
|
1063
|
+
const event = buffer.slice(0, index)
|
|
1064
|
+
buffer = buffer.slice(index + 2)
|
|
1065
|
+
const line = event.split('\n').find((entry) => entry.startsWith('data: '))
|
|
1066
|
+
if (line) {
|
|
1067
|
+
try {
|
|
1068
|
+
const parsed = JSON.parse(line.slice(6))
|
|
1069
|
+
if (parsed && typeof parsed.enc === 'string') {
|
|
1070
|
+
res.write(`data: ${streamPair.open(parsed.enc).toString('utf8')}\n\n`)
|
|
1071
|
+
continue
|
|
1072
|
+
}
|
|
1073
|
+
} catch {
|
|
1074
|
+
continue
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
res.write(`${event}\n\n`)
|
|
1078
|
+
}
|
|
1079
|
+
})
|
|
484
1080
|
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
485
1081
|
})
|
|
486
1082
|
upstream.on('error', () => {
|
|
@@ -505,8 +1101,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
505
1101
|
callPeer,
|
|
506
1102
|
proxyStream,
|
|
507
1103
|
handlePeer,
|
|
1104
|
+
rehandshake: rehandshakePeer,
|
|
1105
|
+
attemptPunch: punchPeer,
|
|
1106
|
+
async clearPunch(peerDeviceId) {
|
|
1107
|
+
const approval = approvals.get(peerDeviceId)
|
|
1108
|
+
if (!approval) return { ok: false, error: 'unknown-peer' }
|
|
1109
|
+
const next = { ...approval }
|
|
1110
|
+
delete next.punch
|
|
1111
|
+
approvals.set(peerDeviceId, next)
|
|
1112
|
+
await saveApprovals()
|
|
1113
|
+
return { ok: true }
|
|
1114
|
+
},
|
|
508
1115
|
setRelay(link) {
|
|
509
1116
|
relay = link
|
|
1117
|
+
if (link && typeof link.setSecurity === 'function') link.setSecurity(security)
|
|
510
1118
|
if (!link) {
|
|
511
1119
|
// Without a relay every relay-only peer is unreachable; keeping it
|
|
512
1120
|
// would make each call fail with a misleading 503.
|
|
@@ -515,6 +1123,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
515
1123
|
}
|
|
516
1124
|
}
|
|
517
1125
|
},
|
|
1126
|
+
setScopes(deviceId, scopes) {
|
|
1127
|
+
const approval = approvals.get(deviceId)
|
|
1128
|
+
if (!approval) return { ok: false, error: 'unknown-device' }
|
|
1129
|
+
const next = Array.isArray(scopes) ? scopes.filter((scope) => DEFAULT_SCOPES.includes(scope)) : []
|
|
1130
|
+
approvals.set(deviceId, { ...approval, scopes: next })
|
|
1131
|
+
saveApprovals().catch(() => {})
|
|
1132
|
+
return { ok: true, scopes: next }
|
|
1133
|
+
},
|
|
1134
|
+
revoke(deviceId) {
|
|
1135
|
+
if (!approvals.delete(deviceId)) return { ok: false, error: 'unknown-device' }
|
|
1136
|
+
ciphers.delete(deviceId)
|
|
1137
|
+
saveApprovals().catch(() => {})
|
|
1138
|
+
return { ok: true }
|
|
1139
|
+
},
|
|
518
1140
|
|
|
519
1141
|
snapshot() {
|
|
520
1142
|
syncRelayPeers()
|
|
@@ -531,6 +1153,16 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
531
1153
|
approved: approvals.has(peer.deviceId),
|
|
532
1154
|
connecting: outgoing.has(peer.deviceId),
|
|
533
1155
|
transport: peer.proto === 'relay' ? 'relay' : 'lan',
|
|
1156
|
+
relay: approvals.get(peer.deviceId)?.relay ?? '',
|
|
1157
|
+
fingerprint: approvals.get(peer.deviceId)?.fingerprint ?? '',
|
|
1158
|
+
forwardSecrecy: Boolean(approvals.get(peer.deviceId)?.peerEphemeral && ephemerals.has(peer.deviceId)),
|
|
1159
|
+
lane: selectLane({ sameLan: peer.proto !== 'relay', punched: Boolean(approvals.get(peer.deviceId)?.punch), relayAvailable: Boolean(relay) }),
|
|
1160
|
+
transport: peer.lastTransport ?? (peer.proto === 'relay' ? 'relay' : 'lan'),
|
|
1161
|
+
punch: approvals.get(peer.deviceId)?.punch ?? null,
|
|
1162
|
+
candidates: Array.isArray(approvals.get(peer.deviceId)?.candidates) ? approvals.get(peer.deviceId).candidates.length : 0,
|
|
1163
|
+
punchPort: peer.punchPort ?? 0,
|
|
1164
|
+
sas: approvals.get(peer.deviceId)?.sas ?? '',
|
|
1165
|
+
scopes: approvals.get(peer.deviceId)?.scopes ?? [],
|
|
534
1166
|
})),
|
|
535
1167
|
requests: [...requests.values()]
|
|
536
1168
|
.filter((request) => request.status === 'pending')
|
|
@@ -540,6 +1172,11 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
|
540
1172
|
deviceName: request.deviceName,
|
|
541
1173
|
userId: request.userId,
|
|
542
1174
|
at: request.at,
|
|
1175
|
+
relay: request.relay ?? '',
|
|
1176
|
+
pairCode: request.pairCode ?? '',
|
|
1177
|
+
sas: request.sas ?? '',
|
|
1178
|
+
fingerprint: request.fingerprint ?? '',
|
|
1179
|
+
relayChanged: Boolean(request.relayChanged),
|
|
543
1180
|
})),
|
|
544
1181
|
}
|
|
545
1182
|
},
|
|
@@ -554,15 +1191,21 @@ function sign(secret, deviceId, method, pathname, timestamp, body) {
|
|
|
554
1191
|
.digest('hex')
|
|
555
1192
|
}
|
|
556
1193
|
|
|
557
|
-
function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
1194
|
+
function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane) {
|
|
558
1195
|
return new Promise((resolve) => {
|
|
559
|
-
|
|
1196
|
+
let text = body === undefined ? '' : JSON.stringify(body)
|
|
1197
|
+
if (lane && body !== undefined) {
|
|
1198
|
+
try { text = JSON.stringify({ enc: lane.seal(text) }) } catch { /* keep plaintext */ }
|
|
1199
|
+
}
|
|
560
1200
|
const headers = { accept: 'application/json' }
|
|
561
1201
|
if (text) {
|
|
562
1202
|
headers['content-type'] = 'application/json'
|
|
563
1203
|
headers['content-length'] = Buffer.byteLength(text)
|
|
564
1204
|
}
|
|
565
|
-
if (
|
|
1205
|
+
if (extraHeaders) {
|
|
1206
|
+
Object.assign(headers, extraHeaders)
|
|
1207
|
+
if (lane) headers['x-dsh-lane'] = 'sealed'
|
|
1208
|
+
} else if (secret && callerId) {
|
|
566
1209
|
const timestamp = String(Date.now())
|
|
567
1210
|
headers['x-dsh-peer-id'] = callerId
|
|
568
1211
|
headers['x-dsh-peer-ts'] = timestamp
|
|
@@ -583,6 +1226,9 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
|
583
1226
|
res.on('end', () => {
|
|
584
1227
|
let parsed
|
|
585
1228
|
try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
|
|
1229
|
+
if (lane && parsed && typeof parsed.enc === 'string') {
|
|
1230
|
+
try { parsed = JSON.parse(lane.open(parsed.enc)) } catch { parsed = { error: 'bad-envelope' } }
|
|
1231
|
+
}
|
|
586
1232
|
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
|
|
587
1233
|
})
|
|
588
1234
|
})
|
|
@@ -592,13 +1238,19 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
|
592
1238
|
})
|
|
593
1239
|
}
|
|
594
1240
|
|
|
595
|
-
function readJson(req) {
|
|
1241
|
+
function readJson(req, maxBytes = 1024 * 1024) {
|
|
596
1242
|
return new Promise((resolve, reject) => {
|
|
1243
|
+
const declared = Number(req.headers?.['content-length'])
|
|
1244
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
1245
|
+
reject(new Error('too large'))
|
|
1246
|
+
req.destroy()
|
|
1247
|
+
return
|
|
1248
|
+
}
|
|
597
1249
|
const chunks = []
|
|
598
1250
|
let size = 0
|
|
599
1251
|
req.on('data', (chunk) => {
|
|
600
1252
|
size += chunk.length
|
|
601
|
-
if (size >
|
|
1253
|
+
if (size > maxBytes) { reject(new Error('too large')); req.destroy(); return }
|
|
602
1254
|
chunks.push(chunk)
|
|
603
1255
|
})
|
|
604
1256
|
req.on('end', () => {
|
|
@@ -619,6 +1271,25 @@ function json(res, status, value) {
|
|
|
619
1271
|
res.end(body)
|
|
620
1272
|
}
|
|
621
1273
|
|
|
1274
|
+
function createFileStore(file, log) {
|
|
1275
|
+
return {
|
|
1276
|
+
async load() {
|
|
1277
|
+
try { return JSON.parse(await readFile(file, 'utf8')) } catch (error) {
|
|
1278
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
|
|
1279
|
+
return null
|
|
1280
|
+
}
|
|
1281
|
+
},
|
|
1282
|
+
async save(value) {
|
|
1283
|
+
try {
|
|
1284
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
1285
|
+
await writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
|
|
1288
|
+
}
|
|
1289
|
+
},
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
622
1293
|
function delay(ms) {
|
|
623
1294
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
624
1295
|
}
|