dsh-remote-control-plugin 0.0.0-stage → 0.3.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 +38 -0
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/client.js +563 -0
- package/cordis.patch.yml +3 -0
- package/host/fabric.js +595 -0
- package/host/relay-core.js +189 -0
- package/host/relay-link.js +335 -0
- package/icon.svg +15 -0
- package/index.js +1011 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +46 -4
- package/web/app.css +420 -0
- package/web/app.js +594 -0
- package/web/icon.svg +15 -0
- package/web/index.html +85 -0
package/host/fabric.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import dgram from 'node:dgram'
|
|
3
|
+
import http from 'node:http'
|
|
4
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const ANNOUNCE_KIND = 'dsh-remote/announce'
|
|
8
|
+
const ANNOUNCE_INTERVAL_MS = 4000
|
|
9
|
+
const PEER_TTL_MS = 15000
|
|
10
|
+
const REQUEST_TTL_MS = 10 * 60 * 1000
|
|
11
|
+
const CLOCK_SKEW_MS = 5 * 60 * 1000
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Peer fabric: LAN discovery, one-time approval, and the authenticated
|
|
15
|
+
* peer-to-peer control calls used by the Devices panel.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is plain Node so it can be exercised without Cordis:
|
|
18
|
+
* `sessions` is a small facade over the Host's session controller.
|
|
19
|
+
*/
|
|
20
|
+
export function createFabric({ log, config, self, sessions, stateFile }) {
|
|
21
|
+
const peers = new Map() // deviceId -> peer record
|
|
22
|
+
const requests = new Map() // requestId -> inbound approval request
|
|
23
|
+
const outgoing = new Map() // deviceId -> { requestId, at }
|
|
24
|
+
const approvals = new Map() // deviceId -> { deviceName, secret, approvedAt }
|
|
25
|
+
const sockets = []
|
|
26
|
+
let announceTimer
|
|
27
|
+
let reaper
|
|
28
|
+
let loaded = false
|
|
29
|
+
let relay
|
|
30
|
+
|
|
31
|
+
const discoveryPort = config.discoveryPort
|
|
32
|
+
const enabled = config.discovery !== false
|
|
33
|
+
|
|
34
|
+
async function start() {
|
|
35
|
+
await loadApprovals()
|
|
36
|
+
if (!enabled) return
|
|
37
|
+
|
|
38
|
+
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
39
|
+
sockets.push(socket)
|
|
40
|
+
socket.on('error', (error) => log.warn(`[remote-control] discovery socket: ${error?.message || error}`))
|
|
41
|
+
socket.on('message', (buffer, rinfo) => {
|
|
42
|
+
try {
|
|
43
|
+
onAnnounce(JSON.parse(buffer.toString('utf8')), rinfo)
|
|
44
|
+
} catch { /* ignore malformed datagrams */ }
|
|
45
|
+
})
|
|
46
|
+
await new Promise((resolve) => {
|
|
47
|
+
socket.bind(discoveryPort, () => {
|
|
48
|
+
try { socket.setBroadcast(true) } catch { /* ignore */ }
|
|
49
|
+
resolve()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
announceTimer = setInterval(announce, ANNOUNCE_INTERVAL_MS)
|
|
54
|
+
announceTimer.unref?.()
|
|
55
|
+
announce()
|
|
56
|
+
reaper = setInterval(reap, 5000)
|
|
57
|
+
reaper.unref?.()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stop() {
|
|
61
|
+
clearInterval(announceTimer)
|
|
62
|
+
clearInterval(reaper)
|
|
63
|
+
for (const socket of sockets) {
|
|
64
|
+
try { socket.close() } catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
sockets.length = 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function announce() {
|
|
70
|
+
const who = self()
|
|
71
|
+
if (!who.deviceId) return
|
|
72
|
+
const payload = Buffer.from(JSON.stringify({
|
|
73
|
+
v: 1,
|
|
74
|
+
kind: ANNOUNCE_KIND,
|
|
75
|
+
deviceId: who.deviceId,
|
|
76
|
+
deviceName: who.deviceName,
|
|
77
|
+
userId: who.userId,
|
|
78
|
+
port: who.port,
|
|
79
|
+
proto: who.proto,
|
|
80
|
+
ts: Date.now(),
|
|
81
|
+
}))
|
|
82
|
+
for (const socket of sockets) {
|
|
83
|
+
try { socket.send(payload, 0, payload.length, discoveryPort, '255.255.255.255') } catch { /* ignore */ }
|
|
84
|
+
try { socket.send(payload, 0, payload.length, discoveryPort, '127.0.0.1') } catch { /* ignore */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function onAnnounce(message, rinfo) {
|
|
89
|
+
if (!message || message.kind !== ANNOUNCE_KIND) return
|
|
90
|
+
if (typeof message.deviceId !== 'string' || !message.deviceId) return
|
|
91
|
+
const who = self()
|
|
92
|
+
if (message.deviceId === who.deviceId) return
|
|
93
|
+
|
|
94
|
+
const existing = peers.get(message.deviceId)
|
|
95
|
+
peers.set(message.deviceId, {
|
|
96
|
+
deviceId: message.deviceId,
|
|
97
|
+
deviceName: typeof message.deviceName === 'string' ? message.deviceName : 'DSH device',
|
|
98
|
+
userId: typeof message.userId === 'string' ? message.userId : null,
|
|
99
|
+
address: rinfo.address,
|
|
100
|
+
port: Number(message.port) || 57330,
|
|
101
|
+
proto: message.proto === 'https' ? 'https' : 'http',
|
|
102
|
+
lastSeen: Date.now(),
|
|
103
|
+
approved: approvals.has(message.deviceId) || Boolean(existing?.approved),
|
|
104
|
+
secret: approvals.get(message.deviceId)?.secret ?? existing?.secret,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reap() {
|
|
109
|
+
const now = Date.now()
|
|
110
|
+
for (const [deviceId, peer] of peers) {
|
|
111
|
+
if (peer.proto === 'relay') continue
|
|
112
|
+
if (now - peer.lastSeen > PEER_TTL_MS) peers.delete(deviceId)
|
|
113
|
+
}
|
|
114
|
+
for (const [requestId, request] of requests) {
|
|
115
|
+
if (now - request.at > REQUEST_TTL_MS) requests.delete(requestId)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge relay presence into the peer table. A directly discovered LAN peer
|
|
121
|
+
* keeps its direct route; relay presence covers devices behind NAT.
|
|
122
|
+
*/
|
|
123
|
+
function syncRelayPeers() {
|
|
124
|
+
if (!relay) return
|
|
125
|
+
const present = new Set()
|
|
126
|
+
for (const item of relay.peers()) {
|
|
127
|
+
present.add(item.deviceId)
|
|
128
|
+
const existing = peers.get(item.deviceId)
|
|
129
|
+
if (existing && existing.proto !== 'relay') continue
|
|
130
|
+
peers.set(item.deviceId, {
|
|
131
|
+
deviceId: item.deviceId,
|
|
132
|
+
deviceName: item.name,
|
|
133
|
+
userId: item.userId,
|
|
134
|
+
address: 'relay',
|
|
135
|
+
port: 0,
|
|
136
|
+
proto: 'relay',
|
|
137
|
+
lastSeen: item.lastSeen,
|
|
138
|
+
approved: approvals.has(item.deviceId) || Boolean(existing?.approved),
|
|
139
|
+
secret: approvals.get(item.deviceId)?.secret ?? existing?.secret,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
// Drop relay peers the relay no longer lists, so a device that leaves or a
|
|
143
|
+
// relay that goes away cannot leave a peer that always answers 503.
|
|
144
|
+
for (const [deviceId, peer] of peers) {
|
|
145
|
+
if (peer.proto === 'relay' && !present.has(deviceId)) peers.delete(deviceId)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function loadApprovals() {
|
|
150
|
+
if (loaded) return
|
|
151
|
+
loaded = true
|
|
152
|
+
try {
|
|
153
|
+
const raw = JSON.parse(await readFile(stateFile, 'utf8'))
|
|
154
|
+
for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
|
|
155
|
+
if (entry && typeof entry.secret === 'string') {
|
|
156
|
+
approvals.set(deviceId, {
|
|
157
|
+
deviceName: entry.deviceName ?? 'DSH device',
|
|
158
|
+
secret: entry.secret,
|
|
159
|
+
approvedAt: entry.approvedAt ?? Date.now(),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${stateFile}: ${error?.message || error}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function saveApprovals() {
|
|
169
|
+
const peersObject = {}
|
|
170
|
+
for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
|
|
171
|
+
try {
|
|
172
|
+
await mkdir(path.dirname(stateFile), { recursive: true })
|
|
173
|
+
await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
log.warn(`[remote-control] could not persist ${stateFile}: ${error?.message || error}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* ---------------- inbound: hello + approval ---------------- */
|
|
180
|
+
|
|
181
|
+
function createHello(req, res) {
|
|
182
|
+
readJson(req).then(async (body) => {
|
|
183
|
+
const deviceId = typeof body?.deviceId === 'string' ? body.deviceId.trim() : ''
|
|
184
|
+
if (!deviceId) return json(res, 400, { error: 'missing-device' })
|
|
185
|
+
if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
|
|
186
|
+
const requestId = randomBytes(16).toString('hex')
|
|
187
|
+
requests.set(requestId, {
|
|
188
|
+
requestId,
|
|
189
|
+
deviceId,
|
|
190
|
+
deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
|
|
191
|
+
userId: typeof body.userId === 'string' ? body.userId : null,
|
|
192
|
+
address: req.socket?.remoteAddress ?? '',
|
|
193
|
+
at: Date.now(),
|
|
194
|
+
status: 'pending',
|
|
195
|
+
})
|
|
196
|
+
log.info(`[remote-control] approval requested by ${deviceId}`)
|
|
197
|
+
return json(res, 200, { requestId, status: 'pending' })
|
|
198
|
+
}).catch((error) => json(res, 400, { error: 'bad-request', message: String(error?.message || error) }))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function helloStatus(res, requestId) {
|
|
202
|
+
const request = requests.get(requestId)
|
|
203
|
+
if (!request) return json(res, 404, { error: 'unknown-request' })
|
|
204
|
+
if (request.status === 'approved') {
|
|
205
|
+
return json(res, 200, { status: 'approved', secret: request.secret, deviceName: self().deviceName })
|
|
206
|
+
}
|
|
207
|
+
return json(res, 200, { status: request.status })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function approveRequest(requestId) {
|
|
211
|
+
const request = requests.get(requestId)
|
|
212
|
+
if (!request) return { ok: false, error: 'unknown-request' }
|
|
213
|
+
const secret = randomBytes(32).toString('hex')
|
|
214
|
+
request.status = 'approved'
|
|
215
|
+
request.secret = secret
|
|
216
|
+
approvals.set(request.deviceId, { deviceName: request.deviceName, secret, approvedAt: Date.now() })
|
|
217
|
+
await saveApprovals()
|
|
218
|
+
const peer = peers.get(request.deviceId)
|
|
219
|
+
if (peer) { peer.approved = true; peer.secret = secret }
|
|
220
|
+
log.info(`[remote-control] approved ${request.deviceName} (${request.deviceId})`)
|
|
221
|
+
return { ok: true }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function denyRequest(requestId) {
|
|
225
|
+
const request = requests.get(requestId)
|
|
226
|
+
if (!request) return { ok: false, error: 'unknown-request' }
|
|
227
|
+
request.status = 'denied'
|
|
228
|
+
return { ok: true }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ---------------- outbound: connect to a peer ---------------- */
|
|
232
|
+
|
|
233
|
+
async function connect(deviceId) {
|
|
234
|
+
syncRelayPeers()
|
|
235
|
+
const peer = peers.get(deviceId)
|
|
236
|
+
if (!peer) return { ok: false, error: 'unknown-peer' }
|
|
237
|
+
if (approvals.has(deviceId)) return { ok: true, approved: true }
|
|
238
|
+
|
|
239
|
+
const who = self()
|
|
240
|
+
const reply = await peerCall(peer, 'POST', '/peer/hello', {
|
|
241
|
+
deviceId: who.deviceId,
|
|
242
|
+
deviceName: who.deviceName,
|
|
243
|
+
userId: who.userId,
|
|
244
|
+
})
|
|
245
|
+
if (!reply.ok || typeof reply.json?.requestId !== 'string') {
|
|
246
|
+
return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
|
|
247
|
+
}
|
|
248
|
+
outgoing.set(deviceId, { requestId: reply.json.requestId, at: Date.now() })
|
|
249
|
+
|
|
250
|
+
// Poll in the background; the target approves in its own Devices panel.
|
|
251
|
+
pollApproval(peer, reply.json.requestId).catch(() => {})
|
|
252
|
+
return { ok: true, approved: false, requestId: reply.json.requestId }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function pollApproval(peer, requestId) {
|
|
256
|
+
for (let attempt = 0; attempt < 300; attempt += 1) {
|
|
257
|
+
await delay(1000)
|
|
258
|
+
if (!outgoing.has(peer.deviceId)) return
|
|
259
|
+
const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
|
|
260
|
+
const status = reply.json?.status
|
|
261
|
+
if (status === 'approved' && typeof reply.json.secret === 'string') {
|
|
262
|
+
approvals.set(peer.deviceId, { deviceName: peer.deviceName, secret: reply.json.secret, approvedAt: Date.now() })
|
|
263
|
+
await saveApprovals()
|
|
264
|
+
peer.approved = true
|
|
265
|
+
peer.secret = reply.json.secret
|
|
266
|
+
outgoing.delete(peer.deviceId)
|
|
267
|
+
log.info(`[remote-control] ${peer.deviceName} approved this device`)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (status === 'denied') {
|
|
271
|
+
outgoing.delete(peer.deviceId)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
outgoing.delete(peer.deviceId)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ---------------- inbound: authenticated peer calls ---------------- */
|
|
279
|
+
|
|
280
|
+
async function handlePeer(req, res, url) {
|
|
281
|
+
const pathname = url.pathname
|
|
282
|
+
if (pathname === '/peer/hello' && req.method === 'POST') return createHello(req, res)
|
|
283
|
+
const helloMatch = pathname.match(/^\/peer\/hello\/([0-9a-f]{32})$/)
|
|
284
|
+
if (helloMatch && req.method === 'GET') return helloStatus(res, helloMatch[1])
|
|
285
|
+
|
|
286
|
+
const verdict = verifyPeer(req, url)
|
|
287
|
+
if (!verdict.ok) return json(res, 403, { error: verdict.error })
|
|
288
|
+
|
|
289
|
+
if (pathname === '/peer/sessions' && req.method === 'GET') {
|
|
290
|
+
const value = await sessions.list()
|
|
291
|
+
return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
|
|
292
|
+
}
|
|
293
|
+
if (pathname === '/peer/stream' && req.method === 'GET') {
|
|
294
|
+
return streamSession(req, res, url)
|
|
295
|
+
}
|
|
296
|
+
if (pathname === '/peer/prompt' && req.method === 'POST') {
|
|
297
|
+
const body = await readJson(req)
|
|
298
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
299
|
+
const text = typeof body?.text === 'string' ? body.text : ''
|
|
300
|
+
if (!sessionId || !text.trim()) return json(res, 400, { error: 'bad-request' })
|
|
301
|
+
const value = await sessions.prompt(sessionId, text)
|
|
302
|
+
return json(res, 200, value)
|
|
303
|
+
}
|
|
304
|
+
if (pathname === '/peer/cancel' && req.method === 'POST') {
|
|
305
|
+
const body = await readJson(req)
|
|
306
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
307
|
+
if (!sessionId) return json(res, 400, { error: 'bad-request' })
|
|
308
|
+
return json(res, 200, sessions.cancel(sessionId))
|
|
309
|
+
}
|
|
310
|
+
return json(res, 404, { error: 'not-found' })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function verifyPeer(req, url) {
|
|
314
|
+
const deviceId = String(req.headers['x-dsh-peer-id'] || '')
|
|
315
|
+
const timestamp = String(req.headers['x-dsh-peer-ts'] || '')
|
|
316
|
+
const signature = String(req.headers['x-dsh-peer-sig'] || '')
|
|
317
|
+
if (!deviceId || !timestamp || !signature) return { ok: false, error: 'missing-signature' }
|
|
318
|
+
const approval = approvals.get(deviceId)
|
|
319
|
+
if (!approval) return { ok: false, error: 'unapproved-device' }
|
|
320
|
+
if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
|
|
321
|
+
return { ok: false, error: 'stale-signature' }
|
|
322
|
+
}
|
|
323
|
+
const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
|
|
324
|
+
const actual = Buffer.from(signature, 'hex')
|
|
325
|
+
const wanted = Buffer.from(expected, 'hex')
|
|
326
|
+
if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
|
|
327
|
+
return { ok: true }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function streamSession(req, res, url) {
|
|
331
|
+
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
332
|
+
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
333
|
+
const controller = new AbortController()
|
|
334
|
+
res.writeHead(200, {
|
|
335
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
336
|
+
'cache-control': 'no-store, no-transform',
|
|
337
|
+
connection: 'keep-alive',
|
|
338
|
+
'x-accel-buffering': 'no',
|
|
339
|
+
})
|
|
340
|
+
res.write(': connected\n\n')
|
|
341
|
+
const heartbeat = setInterval(() => { try { res.write(': ping\n\n') } catch { /* ignore */ } }, 15000)
|
|
342
|
+
heartbeat.unref?.()
|
|
343
|
+
let closed = false
|
|
344
|
+
const cleanup = () => {
|
|
345
|
+
if (closed) return
|
|
346
|
+
closed = true
|
|
347
|
+
clearInterval(heartbeat)
|
|
348
|
+
try { controller.abort() } catch { /* ignore */ }
|
|
349
|
+
try { res.end() } catch { /* ignore */ }
|
|
350
|
+
}
|
|
351
|
+
req.on('close', cleanup)
|
|
352
|
+
req.on('aborted', cleanup)
|
|
353
|
+
try {
|
|
354
|
+
for await (const frame of sessions.follow(sessionId, controller.signal)) {
|
|
355
|
+
if (res.writableEnded || res.destroyed) break
|
|
356
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`)
|
|
357
|
+
}
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (!controller.signal.aborted) {
|
|
360
|
+
try {
|
|
361
|
+
res.write(`data: ${JSON.stringify({ type: 'remote-error', code: error?.code ?? 'error', message: error?.message ?? String(error) })}\n\n`)
|
|
362
|
+
} catch { /* ignore */ }
|
|
363
|
+
}
|
|
364
|
+
} finally {
|
|
365
|
+
cleanup()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ---------------- outbound: control a peer ---------------- */
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One signed call to a peer over whichever transport reaches it: the relay
|
|
373
|
+
* when the peer was discovered there, direct HTTP for a LAN peer.
|
|
374
|
+
*/
|
|
375
|
+
async function peerCall(peer, method, pathname, body, secret) {
|
|
376
|
+
if (peer.proto !== 'relay') {
|
|
377
|
+
return callRaw(peer, method, pathname, body, secret, self().deviceId)
|
|
378
|
+
}
|
|
379
|
+
if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
|
|
380
|
+
const callerId = self().deviceId
|
|
381
|
+
const timestamp = String(Date.now())
|
|
382
|
+
const headers = { accept: 'application/json' }
|
|
383
|
+
if (secret) {
|
|
384
|
+
headers['x-dsh-peer-id'] = callerId
|
|
385
|
+
headers['x-dsh-peer-ts'] = timestamp
|
|
386
|
+
headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
|
|
387
|
+
}
|
|
388
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
389
|
+
let reply
|
|
390
|
+
try {
|
|
391
|
+
reply = await relay.request(peer.deviceId, {
|
|
392
|
+
method,
|
|
393
|
+
path: pathname,
|
|
394
|
+
headers,
|
|
395
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
396
|
+
})
|
|
397
|
+
} catch (error) {
|
|
398
|
+
return { ok: false, status: 502, json: { error: 'relay-failed', message: error?.message ?? String(error) } }
|
|
399
|
+
}
|
|
400
|
+
let parsed
|
|
401
|
+
try { parsed = reply.body ? JSON.parse(reply.body) : {} } catch { parsed = {} }
|
|
402
|
+
return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function callPeer(deviceId, method, pathname, body) {
|
|
406
|
+
syncRelayPeers()
|
|
407
|
+
const peer = peers.get(deviceId)
|
|
408
|
+
if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
|
|
409
|
+
const approval = approvals.get(deviceId)
|
|
410
|
+
if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
|
|
411
|
+
return peerCall(peer, method, pathname, body, approval.secret)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Relay one peer's SSE transcript to a local response (Devices panel). */
|
|
415
|
+
function proxyStream(deviceId, sessionId, res) {
|
|
416
|
+
syncRelayPeers()
|
|
417
|
+
const peer = peers.get(deviceId)
|
|
418
|
+
const approval = approvals.get(deviceId)
|
|
419
|
+
if (!peer || !approval) {
|
|
420
|
+
json(res, 404, { error: 'unknown-peer' })
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
|
|
424
|
+
const timestamp = String(Date.now())
|
|
425
|
+
const callerId = self().deviceId
|
|
426
|
+
if (peer.proto === 'relay') {
|
|
427
|
+
if (!relay) {
|
|
428
|
+
json(res, 503, { error: 'relay-offline' })
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
relay.stream(peer.deviceId, {
|
|
432
|
+
path: target,
|
|
433
|
+
headers: {
|
|
434
|
+
accept: 'text/event-stream',
|
|
435
|
+
'x-dsh-peer-id': callerId,
|
|
436
|
+
'x-dsh-peer-ts': timestamp,
|
|
437
|
+
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
438
|
+
},
|
|
439
|
+
}, res)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const upstream = http.request({
|
|
443
|
+
host: peer.address,
|
|
444
|
+
port: peer.port,
|
|
445
|
+
path: target,
|
|
446
|
+
method: 'GET',
|
|
447
|
+
headers: {
|
|
448
|
+
accept: 'text/event-stream',
|
|
449
|
+
'x-dsh-peer-id': callerId,
|
|
450
|
+
'x-dsh-peer-ts': timestamp,
|
|
451
|
+
'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
|
|
452
|
+
},
|
|
453
|
+
}, (response) => {
|
|
454
|
+
res.writeHead(response.statusCode ?? 502, {
|
|
455
|
+
'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
|
|
456
|
+
'cache-control': 'no-store, no-transform',
|
|
457
|
+
connection: 'keep-alive',
|
|
458
|
+
'x-accel-buffering': 'no',
|
|
459
|
+
})
|
|
460
|
+
response.pipe(res)
|
|
461
|
+
response.on('end', () => { try { res.end() } catch { /* ignore */ } })
|
|
462
|
+
})
|
|
463
|
+
upstream.on('error', () => {
|
|
464
|
+
try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
|
|
465
|
+
})
|
|
466
|
+
res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
|
|
467
|
+
upstream.end()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
start,
|
|
472
|
+
stop,
|
|
473
|
+
connect,
|
|
474
|
+
approveRequest,
|
|
475
|
+
denyRequest,
|
|
476
|
+
callPeer,
|
|
477
|
+
proxyStream,
|
|
478
|
+
handlePeer,
|
|
479
|
+
setRelay(link) {
|
|
480
|
+
relay = link
|
|
481
|
+
if (!link) {
|
|
482
|
+
// Without a relay every relay-only peer is unreachable; keeping it
|
|
483
|
+
// would make each call fail with a misleading 503.
|
|
484
|
+
for (const [deviceId, peer] of peers) {
|
|
485
|
+
if (peer.proto === 'relay') peers.delete(deviceId)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
snapshot() {
|
|
491
|
+
syncRelayPeers()
|
|
492
|
+
const who = self()
|
|
493
|
+
return {
|
|
494
|
+
self: who,
|
|
495
|
+
peers: [...peers.values()].map((peer) => ({
|
|
496
|
+
deviceId: peer.deviceId,
|
|
497
|
+
deviceName: peer.deviceName,
|
|
498
|
+
userId: peer.userId,
|
|
499
|
+
address: peer.address,
|
|
500
|
+
port: peer.port,
|
|
501
|
+
lastSeen: peer.lastSeen,
|
|
502
|
+
approved: approvals.has(peer.deviceId),
|
|
503
|
+
connecting: outgoing.has(peer.deviceId),
|
|
504
|
+
transport: peer.proto === 'relay' ? 'relay' : 'lan',
|
|
505
|
+
})),
|
|
506
|
+
requests: [...requests.values()]
|
|
507
|
+
.filter((request) => request.status === 'pending')
|
|
508
|
+
.map((request) => ({
|
|
509
|
+
requestId: request.requestId,
|
|
510
|
+
deviceId: request.deviceId,
|
|
511
|
+
deviceName: request.deviceName,
|
|
512
|
+
userId: request.userId,
|
|
513
|
+
at: request.at,
|
|
514
|
+
})),
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/* ---------------- HTTP helpers ---------------- */
|
|
521
|
+
|
|
522
|
+
function sign(secret, deviceId, method, pathname, timestamp, body) {
|
|
523
|
+
return createHmac('sha256', secret)
|
|
524
|
+
.update(`${deviceId}\n${method}\n${pathname}\n${timestamp}\n${body}`)
|
|
525
|
+
.digest('hex')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function callRaw(peer, method, pathname, body, secret, callerId) {
|
|
529
|
+
return new Promise((resolve) => {
|
|
530
|
+
const text = body === undefined ? '' : JSON.stringify(body)
|
|
531
|
+
const headers = { accept: 'application/json' }
|
|
532
|
+
if (text) {
|
|
533
|
+
headers['content-type'] = 'application/json'
|
|
534
|
+
headers['content-length'] = Buffer.byteLength(text)
|
|
535
|
+
}
|
|
536
|
+
if (secret && callerId) {
|
|
537
|
+
const timestamp = String(Date.now())
|
|
538
|
+
headers['x-dsh-peer-id'] = callerId
|
|
539
|
+
headers['x-dsh-peer-ts'] = timestamp
|
|
540
|
+
headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
|
|
541
|
+
}
|
|
542
|
+
const transport = peer.proto === 'https' ? null : http
|
|
543
|
+
if (!transport) return resolve({ ok: false, error: 'tls-unsupported', json: { error: 'tls-unsupported' } })
|
|
544
|
+
const req = transport.request({
|
|
545
|
+
host: peer.address,
|
|
546
|
+
port: peer.port,
|
|
547
|
+
path: pathname,
|
|
548
|
+
method,
|
|
549
|
+
headers,
|
|
550
|
+
timeout: 15000,
|
|
551
|
+
}, (res) => {
|
|
552
|
+
let data = ''
|
|
553
|
+
res.on('data', (chunk) => { data += chunk })
|
|
554
|
+
res.on('end', () => {
|
|
555
|
+
let parsed
|
|
556
|
+
try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
|
|
557
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
req.on('timeout', () => { req.destroy(new Error('timeout')) })
|
|
561
|
+
req.on('error', (error) => resolve({ ok: false, error: error?.message || 'request-failed', json: {} }))
|
|
562
|
+
req.end(text || undefined)
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function readJson(req) {
|
|
567
|
+
return new Promise((resolve, reject) => {
|
|
568
|
+
const chunks = []
|
|
569
|
+
let size = 0
|
|
570
|
+
req.on('data', (chunk) => {
|
|
571
|
+
size += chunk.length
|
|
572
|
+
if (size > 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
|
|
573
|
+
chunks.push(chunk)
|
|
574
|
+
})
|
|
575
|
+
req.on('end', () => {
|
|
576
|
+
if (chunks.length === 0) return resolve({})
|
|
577
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
|
|
578
|
+
})
|
|
579
|
+
req.on('error', reject)
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function json(res, status, value) {
|
|
584
|
+
const body = JSON.stringify(value)
|
|
585
|
+
res.writeHead(status, {
|
|
586
|
+
'content-type': 'application/json; charset=utf-8',
|
|
587
|
+
'content-length': Buffer.byteLength(body),
|
|
588
|
+
'cache-control': 'no-store',
|
|
589
|
+
})
|
|
590
|
+
res.end(body)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function delay(ms) {
|
|
594
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
595
|
+
}
|