dsh-remote-control-plugin 0.0.0-stage → 0.3.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 +35 -0
- package/README.md +96 -2
- package/client.js +563 -0
- package/cordis.patch.yml +3 -0
- package/host/fabric.js +578 -0
- package/host/relay-core.js +189 -0
- package/host/relay-link.js +335 -0
- package/icon.svg +15 -0
- package/index.js +1010 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +45 -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/index.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { createServer as createHttpServer } from 'node:http'
|
|
4
|
+
import { createServer as createHttpsServer } from 'node:https'
|
|
5
|
+
import { isIP } from 'node:net'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { createFabric } from './host/fabric.js'
|
|
10
|
+
import { createRelayLink } from './host/relay-link.js'
|
|
11
|
+
import { createRelay } from './host/relay-core.js'
|
|
12
|
+
|
|
13
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const WEB_DIR = path.join(HERE, 'web')
|
|
15
|
+
const COOKIE_NAME = 'dsh_rc'
|
|
16
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
const STATIC_FILES = {
|
|
19
|
+
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
20
|
+
'/index.html': ['index.html', 'text/html; charset=utf-8'],
|
|
21
|
+
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
22
|
+
'/app.css': ['app.css', 'text/css; charset=utf-8'],
|
|
23
|
+
'/icon.svg': ['icon.svg', 'image/svg+xml'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const inject = ['sessionController']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Phone-friendly remote control for this Host's Sessions.
|
|
30
|
+
*
|
|
31
|
+
* The plugin owns its own listener instead of reusing the loopback web
|
|
32
|
+
* carrier, so it never depends on the GUI's boot payload, cookies, or the
|
|
33
|
+
* `/api` bridge. Everything it renders is derived from
|
|
34
|
+
* `ctx.sessionController` (list / follow / prompt / cancel / create).
|
|
35
|
+
*/
|
|
36
|
+
export function apply(ctx, rawConfig) {
|
|
37
|
+
const log = pickLogger(ctx)
|
|
38
|
+
const config = normalizeConfig(rawConfig)
|
|
39
|
+
const stateFile = path.join(dshHome(), 'remote-control.json')
|
|
40
|
+
const peersFile = path.join(dshHome(), 'remote-control-peers.json')
|
|
41
|
+
const streams = new Set()
|
|
42
|
+
const published = { urls: [], code: '' }
|
|
43
|
+
const info = { port: config.port, proto: config.tls ? 'https' : 'http' }
|
|
44
|
+
const identity = { userId: null }
|
|
45
|
+
|
|
46
|
+
let server
|
|
47
|
+
let state
|
|
48
|
+
let fabric
|
|
49
|
+
let relayLink
|
|
50
|
+
let relayCore
|
|
51
|
+
let hostedKey = ''
|
|
52
|
+
|
|
53
|
+
const sessions = {
|
|
54
|
+
async list() {
|
|
55
|
+
const value = await ctx.sessionController.list({}, AbortSignal.timeout(15000))
|
|
56
|
+
return (value?.items ?? [])
|
|
57
|
+
.filter((item) => item.origin !== 'subagent')
|
|
58
|
+
.map((item) => {
|
|
59
|
+
const projections = item.projections?.values ?? {}
|
|
60
|
+
const title = firstString(projections.title, projections.sessionTitle, projections['session-title'])
|
|
61
|
+
return {
|
|
62
|
+
id: item.sessionId,
|
|
63
|
+
title: title || '',
|
|
64
|
+
running: Boolean(item.running),
|
|
65
|
+
blank: Boolean(item.blank),
|
|
66
|
+
updatedAt: item.updatedAt ?? 0,
|
|
67
|
+
cwd: item.cwd ?? '',
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
71
|
+
},
|
|
72
|
+
follow(sessionId, signal) {
|
|
73
|
+
return ctx.sessionController.follow(
|
|
74
|
+
{ address: { kind: 'session', sessionId }, assistantStream: true },
|
|
75
|
+
signal,
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
prompt(sessionId, text) {
|
|
79
|
+
return ctx.sessionController.prompt({
|
|
80
|
+
requestId: randomUUID(),
|
|
81
|
+
sessionId,
|
|
82
|
+
mode: 'queue',
|
|
83
|
+
content: [{ type: 'text', text }],
|
|
84
|
+
clientTimeZone: timeZone(),
|
|
85
|
+
}, AbortSignal.timeout(30000))
|
|
86
|
+
},
|
|
87
|
+
cancel(sessionId) {
|
|
88
|
+
return ctx.sessionController.cancel({ sessionId })
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fabric = createFabric({
|
|
93
|
+
log,
|
|
94
|
+
config,
|
|
95
|
+
stateFile: peersFile,
|
|
96
|
+
self: () => ({
|
|
97
|
+
deviceId: state?.deviceId ?? '',
|
|
98
|
+
deviceName: config.deviceName,
|
|
99
|
+
userId: identity.userId,
|
|
100
|
+
port: info.port,
|
|
101
|
+
proto: info.proto,
|
|
102
|
+
}),
|
|
103
|
+
sessions,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const ready = loadState(stateFile, config, log)
|
|
107
|
+
.then(async (loaded) => {
|
|
108
|
+
state = loaded
|
|
109
|
+
await refreshIdentity()
|
|
110
|
+
await start()
|
|
111
|
+
try {
|
|
112
|
+
await fabric.start()
|
|
113
|
+
} catch (error) {
|
|
114
|
+
log.warn(`[remote-control] peer discovery disabled: ${error?.message || error}`)
|
|
115
|
+
}
|
|
116
|
+
if (config.relayUrl && config.relayKey) {
|
|
117
|
+
try {
|
|
118
|
+
await startRelay(config.relayUrl, config.relayKey)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
log.warn(`[remote-control] relay disabled: ${error?.message || error}`)
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const persisted = await loadRelayConfig()
|
|
124
|
+
if (persisted?.hosted && persisted.key) {
|
|
125
|
+
try {
|
|
126
|
+
await startHostedRelay(persisted.key)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
log.warn(`[remote-control] hosted relay disabled: ${error?.message || error}`)
|
|
129
|
+
}
|
|
130
|
+
} else if (persisted) {
|
|
131
|
+
try {
|
|
132
|
+
await startRelay(persisted.url, persisted.key)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
log.warn(`[remote-control] relay disabled: ${error?.message || error}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const refresh = setInterval(() => { refreshIdentity().catch(() => {}) }, 60000)
|
|
139
|
+
refresh.unref?.()
|
|
140
|
+
ctx.effect(() => () => clearInterval(refresh))
|
|
141
|
+
})
|
|
142
|
+
.catch((error) => {
|
|
143
|
+
log.error(`[remote-control] disabled: ${error?.message || error}`)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
async function refreshIdentity() {
|
|
147
|
+
const account = typeof ctx.get === 'function' ? safely(() => ctx.get('deepseekAccount')) : undefined
|
|
148
|
+
if (!account || typeof account.getDeviceIdentity !== 'function') return
|
|
149
|
+
try {
|
|
150
|
+
const value = await account.getDeviceIdentity()
|
|
151
|
+
identity.userId = value?.userId ?? null
|
|
152
|
+
} catch { /* signed out or unavailable */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const relayFile = path.join(dshHome(), 'remote-control-relay.json')
|
|
156
|
+
|
|
157
|
+
async function loadRelayConfig() {
|
|
158
|
+
try {
|
|
159
|
+
const raw = JSON.parse(await readFile(relayFile, 'utf8'))
|
|
160
|
+
if (raw && typeof raw.url === 'string' && typeof raw.key === 'string') return { url: raw.url, key: raw.key }
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${relayFile}: ${error?.message || error}`)
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function saveRelayConfig(value) {
|
|
168
|
+
try {
|
|
169
|
+
await writeFile(relayFile, JSON.stringify(value, null, 2), { mode: 0o600 })
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log.warn(`[remote-control] could not persist ${relayFile}: ${error?.message || error}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function clearRelayConfig() {
|
|
176
|
+
try { await rm(relayFile, { force: true }) } catch { /* ignore */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function startRelay(url, key) {
|
|
180
|
+
stopRelay()
|
|
181
|
+
const link = createRelayLink({
|
|
182
|
+
url,
|
|
183
|
+
key,
|
|
184
|
+
deviceId: state.deviceId,
|
|
185
|
+
deviceName: config.deviceName,
|
|
186
|
+
userId: identity.userId,
|
|
187
|
+
localPort: info.port,
|
|
188
|
+
log,
|
|
189
|
+
})
|
|
190
|
+
await link.start()
|
|
191
|
+
relayLink = link
|
|
192
|
+
fabric.setRelay(link)
|
|
193
|
+
log.info(`[remote-control] relay connected: ${url}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stopRelay() {
|
|
197
|
+
if (!relayLink) return
|
|
198
|
+
try { relayLink.stop() } catch { /* ignore */ }
|
|
199
|
+
relayLink = undefined
|
|
200
|
+
try { fabric?.setRelay(undefined) } catch { /* ignore */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Serve the relay from this Host and point our own link at it. */
|
|
204
|
+
async function startHostedRelay(key) {
|
|
205
|
+
stopHostedRelay()
|
|
206
|
+
relayCore = createRelay({ log })
|
|
207
|
+
hostedKey = key
|
|
208
|
+
try {
|
|
209
|
+
await startRelay(`http://127.0.0.1:${info.port}`, key)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
stopHostedRelay()
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stopHostedRelay() {
|
|
217
|
+
if (relayCore) {
|
|
218
|
+
try { relayCore.close() } catch { /* ignore */ }
|
|
219
|
+
relayCore = undefined
|
|
220
|
+
}
|
|
221
|
+
hostedKey = ''
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function shareUrls() {
|
|
225
|
+
if (config.publicUrl) return [config.publicUrl.replace(/\/+$/, '')]
|
|
226
|
+
return accessUrls(config, info.port, info.proto)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ctx.effect(() => () => {
|
|
230
|
+
for (const controller of streams) {
|
|
231
|
+
try { controller.abort() } catch { /* ignore */ }
|
|
232
|
+
}
|
|
233
|
+
streams.clear()
|
|
234
|
+
try { relayLink?.stop() } catch { /* ignore */ }
|
|
235
|
+
try { relayCore?.close() } catch { /* ignore */ }
|
|
236
|
+
try { fabric?.stop() } catch { /* ignore */ }
|
|
237
|
+
if (server) {
|
|
238
|
+
try { server.close() } catch { /* ignore */ }
|
|
239
|
+
server = undefined
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// Discoverability: the local GUI can show the pairing code and reachable URLs.
|
|
244
|
+
// The handler refuses any non-loopback Host, so it never leaks over a LAN bind.
|
|
245
|
+
const webServer = typeof ctx.get === 'function' ? safely(() => ctx.get('webServer')) : undefined
|
|
246
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
247
|
+
ctx.effect(() => webServer.register({
|
|
248
|
+
kind: 'exact',
|
|
249
|
+
path: '/remote-control',
|
|
250
|
+
handler: (req, res) => serveInfo(req, res, published),
|
|
251
|
+
}))
|
|
252
|
+
ctx.effect(() => webServer.register({
|
|
253
|
+
kind: 'prefix',
|
|
254
|
+
path: '/remote-control/api',
|
|
255
|
+
handler: (req, res) => {
|
|
256
|
+
handleConsole(req, res).catch((error) => {
|
|
257
|
+
log.warn(`[remote-control] console call failed: ${error?.message || error}`)
|
|
258
|
+
try { json(res, 500, { error: 'internal', message: String(error?.message || error) }) } catch { /* ignore */ }
|
|
259
|
+
})
|
|
260
|
+
},
|
|
261
|
+
}))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function handleConsole(req, res) {
|
|
265
|
+
if (!consoleAllowed(req)) return json(res, 403, { error: 'forbidden' })
|
|
266
|
+
if (!fabric) return json(res, 503, { error: 'starting' })
|
|
267
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
268
|
+
const action = url.pathname.slice('/remote-control/api'.length)
|
|
269
|
+
|
|
270
|
+
if (action === '/state' && req.method === 'GET') {
|
|
271
|
+
const snapshot = fabric.snapshot()
|
|
272
|
+
const urls = relayCore ? shareUrls() : []
|
|
273
|
+
snapshot.relay = {
|
|
274
|
+
url: relayLink?.url() ?? '',
|
|
275
|
+
connected: relayLink?.connected() ?? false,
|
|
276
|
+
peers: relayLink?.peers().length ?? 0,
|
|
277
|
+
hosted: Boolean(relayCore),
|
|
278
|
+
urls,
|
|
279
|
+
invite: relayCore && hostedKey && urls[0] ? `${urls[0]} ${hostedKey}` : '',
|
|
280
|
+
}
|
|
281
|
+
return json(res, 200, snapshot)
|
|
282
|
+
}
|
|
283
|
+
if (action === '/relay/host' && req.method === 'POST') {
|
|
284
|
+
const body = await readJson(req)
|
|
285
|
+
if (body?.enabled === false) {
|
|
286
|
+
stopRelay()
|
|
287
|
+
stopHostedRelay()
|
|
288
|
+
await clearRelayConfig()
|
|
289
|
+
return json(res, 200, { ok: true })
|
|
290
|
+
}
|
|
291
|
+
let key = typeof body?.key === 'string' && body.key.length >= 16 ? body.key : ''
|
|
292
|
+
if (!key) {
|
|
293
|
+
const persisted = await loadRelayConfig()
|
|
294
|
+
key = persisted?.hosted && persisted.key ? persisted.key : randomBytes(16).toString('hex')
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await startHostedRelay(key)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return json(res, 502, { error: 'relay-failed', message: String(error?.message || error) })
|
|
300
|
+
}
|
|
301
|
+
const urls = shareUrls()
|
|
302
|
+
await saveRelayConfig({ hosted: true, url: urls[0] ?? '', key })
|
|
303
|
+
log.info(`[remote-control] hosting a relay at ${urls[0] ?? '(no address)'}`)
|
|
304
|
+
return json(res, 200, { ok: true, urls, invite: `${urls[0] ?? ''} ${key}` })
|
|
305
|
+
}
|
|
306
|
+
if (action === '/relay' && req.method === 'POST') {
|
|
307
|
+
const body = await readJson(req)
|
|
308
|
+
const url = String(body?.url || '').trim()
|
|
309
|
+
const key = String(body?.key || '')
|
|
310
|
+
let parsed
|
|
311
|
+
try { parsed = new URL(url) } catch { return json(res, 400, { error: 'bad-url', message: 'Relay URL must be absolute.' }) }
|
|
312
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
313
|
+
return json(res, 400, { error: 'bad-url', message: 'Relay URL must use http or https.' })
|
|
314
|
+
}
|
|
315
|
+
if (key.length < 16) {
|
|
316
|
+
return json(res, 400, { error: 'bad-key', message: 'Workspace key must be at least 16 characters.' })
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
stopHostedRelay()
|
|
320
|
+
await startRelay(url, key)
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return json(res, 502, { error: 'relay-unreachable', message: String(error?.message || error) })
|
|
323
|
+
}
|
|
324
|
+
await saveRelayConfig({ hosted: false, url, key })
|
|
325
|
+
return json(res, 200, { ok: true })
|
|
326
|
+
}
|
|
327
|
+
if (action === '/relay/clear' && req.method === 'POST') {
|
|
328
|
+
stopRelay()
|
|
329
|
+
stopHostedRelay()
|
|
330
|
+
await clearRelayConfig()
|
|
331
|
+
return json(res, 200, { ok: true })
|
|
332
|
+
}
|
|
333
|
+
if (action === '/approve' && req.method === 'POST') {
|
|
334
|
+
const body = await readJson(req)
|
|
335
|
+
return json(res, 200, await fabric.approveRequest(String(body?.requestId || '')))
|
|
336
|
+
}
|
|
337
|
+
if (action === '/deny' && req.method === 'POST') {
|
|
338
|
+
const body = await readJson(req)
|
|
339
|
+
return json(res, 200, fabric.denyRequest(String(body?.requestId || '')))
|
|
340
|
+
}
|
|
341
|
+
if (action === '/connect' && req.method === 'POST') {
|
|
342
|
+
const body = await readJson(req)
|
|
343
|
+
return json(res, 200, await fabric.connect(String(body?.deviceId || '')))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel)$/)
|
|
347
|
+
if (match) {
|
|
348
|
+
const deviceId = decodeURIComponent(match[1])
|
|
349
|
+
const operation = match[2]
|
|
350
|
+
if (operation === 'sessions' && req.method === 'GET') {
|
|
351
|
+
const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions')
|
|
352
|
+
return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
|
|
353
|
+
}
|
|
354
|
+
if (operation === 'stream' && req.method === 'GET') {
|
|
355
|
+
return fabric.proxyStream(deviceId, String(url.searchParams.get('sessionId') || ''), res)
|
|
356
|
+
}
|
|
357
|
+
if (operation === 'prompt' && req.method === 'POST') {
|
|
358
|
+
const body = await readJson(req)
|
|
359
|
+
const reply = await fabric.callPeer(deviceId, 'POST', '/peer/prompt', {
|
|
360
|
+
sessionId: body?.sessionId,
|
|
361
|
+
text: body?.text,
|
|
362
|
+
})
|
|
363
|
+
return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
|
|
364
|
+
}
|
|
365
|
+
if (operation === 'cancel' && req.method === 'POST') {
|
|
366
|
+
const body = await readJson(req)
|
|
367
|
+
const reply = await fabric.callPeer(deviceId, 'POST', '/peer/cancel', { sessionId: body?.sessionId })
|
|
368
|
+
return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return json(res, 404, { error: 'not-found' })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function start() {
|
|
375
|
+
const options = {}
|
|
376
|
+
let factory = createHttpServer
|
|
377
|
+
let scheme = 'http'
|
|
378
|
+
if (config.tls) {
|
|
379
|
+
const [cert, key] = await Promise.all([
|
|
380
|
+
readFile(path.resolve(config.tls.certFile)),
|
|
381
|
+
readFile(path.resolve(config.tls.keyFile)),
|
|
382
|
+
])
|
|
383
|
+
options.cert = cert
|
|
384
|
+
options.key = key
|
|
385
|
+
factory = createHttpsServer
|
|
386
|
+
scheme = 'https'
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
server = factory(options, (req, res) => {
|
|
390
|
+
handle(req, res).catch((error) => {
|
|
391
|
+
log.error(`[remote-control] request failed: ${error?.stack || error}`)
|
|
392
|
+
try { json(res, 500, { error: 'internal', message: String(error?.message || error) }) } catch { /* ignore */ }
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
server.on('clientError', (_error, socket) => {
|
|
396
|
+
try { socket.destroy() } catch { /* ignore */ }
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
await new Promise((resolve, reject) => {
|
|
400
|
+
const onError = (error) => reject(error)
|
|
401
|
+
server.once('error', onError)
|
|
402
|
+
server.listen(config.port, config.host, () => {
|
|
403
|
+
server.off('error', onError)
|
|
404
|
+
resolve()
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
server.on('error', (error) => log.error(`[remote-control] listener error: ${error?.message || error}`))
|
|
409
|
+
|
|
410
|
+
const port = server.address()?.port ?? config.port
|
|
411
|
+
info.port = port
|
|
412
|
+
info.proto = scheme
|
|
413
|
+
const urls = accessUrls(config, port, scheme)
|
|
414
|
+
published.urls = urls
|
|
415
|
+
published.code = state.code
|
|
416
|
+
log.info(`[remote-control] pairing code: ${state.code}`)
|
|
417
|
+
log.info(`[remote-control] open one of: ${urls.join(' ')}`)
|
|
418
|
+
log.info(`[remote-control] state file: ${stateFile} (delete it to rotate the code)`)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handle(req, res) {
|
|
422
|
+
const host = String(req.headers.host || '')
|
|
423
|
+
if (!hostAllowed(host, config)) {
|
|
424
|
+
return json(res, 403, { error: 'host-not-allowed', message: 'This Host header is not trusted by the remote-control listener.' })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const url = new URL(req.url || '/', `http://${host || 'localhost'}`)
|
|
428
|
+
const pathname = url.pathname
|
|
429
|
+
|
|
430
|
+
if (pathname === '/peer' || pathname.startsWith('/peer/')) {
|
|
431
|
+
return fabric.handlePeer(req, res, url)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (relayCore && pathname.startsWith('/v1/')) {
|
|
435
|
+
return relayCore.handle(req, res)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (req.method === 'GET' && STATIC_FILES[pathname]) {
|
|
439
|
+
const [file, type] = STATIC_FILES[pathname]
|
|
440
|
+
return serveStatic(res, file, type)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (pathname === '/api/me') {
|
|
444
|
+
return json(res, 200, {
|
|
445
|
+
authenticated: isAuthenticated(req, state.secret),
|
|
446
|
+
name: 'DSH Remote Control',
|
|
447
|
+
version: '0.1.0',
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (pathname === '/api/pair' && req.method === 'POST') {
|
|
452
|
+
return pair(req, res)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (pathname.startsWith('/api/')) {
|
|
456
|
+
if (!isAuthenticated(req, state.secret)) {
|
|
457
|
+
return json(res, 401, { error: 'unauthorized' })
|
|
458
|
+
}
|
|
459
|
+
if (req.method === 'POST' && !sameOrigin(req, config)) {
|
|
460
|
+
return json(res, 403, { error: 'origin-not-allowed' })
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (pathname === '/api/logout' && req.method === 'POST') {
|
|
465
|
+
res.setHeader('set-cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`)
|
|
466
|
+
return json(res, 200, { ok: true })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (pathname === '/api/sessions' && req.method === 'GET') {
|
|
470
|
+
if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
|
|
471
|
+
return listSessions(res)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (pathname === '/api/stream' && req.method === 'GET') {
|
|
475
|
+
if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
|
|
476
|
+
return streamSession(req, res, url)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (pathname === '/api/prompt' && req.method === 'POST') {
|
|
480
|
+
if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
|
|
481
|
+
return promptSession(req, res)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (pathname === '/api/cancel' && req.method === 'POST') {
|
|
485
|
+
if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
|
|
486
|
+
return cancelSession(req, res)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (pathname === '/api/new-session' && req.method === 'POST') {
|
|
490
|
+
if (!ctx.sessionController) return json(res, 503, { error: 'session-controller-unavailable' })
|
|
491
|
+
return newSession(req, res)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return json(res, 404, { error: 'not-found' })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function pair(req, res) {
|
|
498
|
+
if (!sameOrigin(req, config)) return json(res, 403, { error: 'origin-not-allowed' })
|
|
499
|
+
const body = await readJson(req)
|
|
500
|
+
const code = typeof body?.code === 'string' ? body.code.trim() : ''
|
|
501
|
+
const ip = clientIp(req)
|
|
502
|
+
if (tooManyAttempts(ip)) {
|
|
503
|
+
return json(res, 429, { error: 'rate-limited', message: 'Too many attempts. Wait a few minutes and try again.' })
|
|
504
|
+
}
|
|
505
|
+
if (!constantTimeEquals(code, state.code)) {
|
|
506
|
+
recordFailure(ip)
|
|
507
|
+
log.warn(`[remote-control] rejected pairing attempt from ${ip}`)
|
|
508
|
+
return json(res, 401, { error: 'bad-code', message: 'That pairing code is not correct.' })
|
|
509
|
+
}
|
|
510
|
+
recordSuccess(ip)
|
|
511
|
+
const token = issueToken(state.secret, config.cookieDays)
|
|
512
|
+
const attributes = [
|
|
513
|
+
`${COOKIE_NAME}=${token}`,
|
|
514
|
+
'Path=/',
|
|
515
|
+
'HttpOnly',
|
|
516
|
+
'SameSite=Strict',
|
|
517
|
+
`Max-Age=${Math.round(Math.max(1, config.cookieDays) * 86400)}`,
|
|
518
|
+
]
|
|
519
|
+
if (config.tls) attributes.push('Secure')
|
|
520
|
+
res.setHeader('set-cookie', attributes.join('; '))
|
|
521
|
+
log.info(`[remote-control] paired a browser from ${ip}`)
|
|
522
|
+
return json(res, 200, { ok: true })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function listSessions(res) {
|
|
526
|
+
const items = await sessions.list()
|
|
527
|
+
return json(res, 200, { items })
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function streamSession(req, res, url) {
|
|
531
|
+
const sessionId = String(url.searchParams.get('sessionId') || '')
|
|
532
|
+
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
533
|
+
|
|
534
|
+
const controller = new AbortController()
|
|
535
|
+
streams.add(controller)
|
|
536
|
+
res.writeHead(200, {
|
|
537
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
538
|
+
'cache-control': 'no-store, no-transform',
|
|
539
|
+
connection: 'keep-alive',
|
|
540
|
+
'x-accel-buffering': 'no',
|
|
541
|
+
})
|
|
542
|
+
res.write(': connected\n\n')
|
|
543
|
+
|
|
544
|
+
const heartbeat = setInterval(() => {
|
|
545
|
+
try { res.write(': ping\n\n') } catch { /* ignore */ }
|
|
546
|
+
}, 15000)
|
|
547
|
+
heartbeat.unref?.()
|
|
548
|
+
|
|
549
|
+
let closed = false
|
|
550
|
+
const cleanup = () => {
|
|
551
|
+
if (closed) return
|
|
552
|
+
closed = true
|
|
553
|
+
clearInterval(heartbeat)
|
|
554
|
+
streams.delete(controller)
|
|
555
|
+
try { controller.abort() } catch { /* ignore */ }
|
|
556
|
+
try { res.end() } catch { /* ignore */ }
|
|
557
|
+
}
|
|
558
|
+
req.on('close', cleanup)
|
|
559
|
+
req.on('aborted', cleanup)
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const frames = ctx.sessionController.follow(
|
|
563
|
+
{ address: { kind: 'session', sessionId }, assistantStream: true },
|
|
564
|
+
controller.signal,
|
|
565
|
+
)
|
|
566
|
+
for await (const frame of frames) {
|
|
567
|
+
if (res.writableEnded || res.destroyed) break
|
|
568
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`)
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (!controller.signal.aborted) {
|
|
572
|
+
const payload = { type: 'remote-error', code: error?.code ?? 'error', message: error?.message ?? String(error) }
|
|
573
|
+
try { res.write(`data: ${JSON.stringify(payload)}\n\n`) } catch { /* ignore */ }
|
|
574
|
+
log.warn(`[remote-control] follow failed for ${sessionId}: ${error?.message || error}`)
|
|
575
|
+
}
|
|
576
|
+
} finally {
|
|
577
|
+
cleanup()
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function promptSession(req, res) {
|
|
582
|
+
const body = await readJson(req)
|
|
583
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
584
|
+
const text = typeof body?.text === 'string' ? body.text : ''
|
|
585
|
+
const mode = body?.mode === 'steer' ? 'steer' : 'queue'
|
|
586
|
+
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
587
|
+
if (!text.trim()) return json(res, 400, { error: 'empty-prompt', message: 'Write a message first.' })
|
|
588
|
+
|
|
589
|
+
const accepted = await ctx.sessionController.prompt({
|
|
590
|
+
requestId: randomUUID(),
|
|
591
|
+
sessionId,
|
|
592
|
+
mode,
|
|
593
|
+
content: [{ type: 'text', text }],
|
|
594
|
+
clientTimeZone: timeZone(),
|
|
595
|
+
}, AbortSignal.timeout(30000))
|
|
596
|
+
return json(res, 200, accepted ?? { accepted: true })
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function cancelSession(req, res) {
|
|
600
|
+
const body = await readJson(req)
|
|
601
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
602
|
+
if (!sessionId) return json(res, 400, { error: 'missing-session' })
|
|
603
|
+
const value = ctx.sessionController.cancel({ sessionId })
|
|
604
|
+
return json(res, 200, value ?? { accepted: true })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function newSession(req, res) {
|
|
608
|
+
const body = await readJson(req)
|
|
609
|
+
const request = {}
|
|
610
|
+
if (typeof body?.cwd === 'string' && body.cwd.trim()) request.cwd = body.cwd.trim()
|
|
611
|
+
else if (config.defaultCwd) request.cwd = config.defaultCwd
|
|
612
|
+
const value = await ctx.sessionController.create(request)
|
|
613
|
+
return json(res, 200, value)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function serveStatic(res, file, type) {
|
|
617
|
+
try {
|
|
618
|
+
const data = await readFile(path.join(WEB_DIR, file))
|
|
619
|
+
res.writeHead(200, {
|
|
620
|
+
'content-type': type,
|
|
621
|
+
'cache-control': 'no-store',
|
|
622
|
+
'content-length': data.length,
|
|
623
|
+
'x-content-type-options': 'nosniff',
|
|
624
|
+
})
|
|
625
|
+
res.end(data)
|
|
626
|
+
} catch {
|
|
627
|
+
json(res, 404, { error: 'not-found' })
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function json(res, status, value) {
|
|
632
|
+
const body = JSON.stringify(value)
|
|
633
|
+
res.writeHead(status, {
|
|
634
|
+
'content-type': 'application/json; charset=utf-8',
|
|
635
|
+
'content-length': Buffer.byteLength(body),
|
|
636
|
+
'cache-control': 'no-store',
|
|
637
|
+
'x-content-type-options': 'nosniff',
|
|
638
|
+
})
|
|
639
|
+
res.end(body)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/* ------------------------------------------------------------------ */
|
|
644
|
+
/* loopback discovery page */
|
|
645
|
+
/* ------------------------------------------------------------------ */
|
|
646
|
+
|
|
647
|
+
function serveInfo(req, res, published) {
|
|
648
|
+
const headers = {
|
|
649
|
+
'content-type': 'text/html; charset=utf-8',
|
|
650
|
+
'cache-control': 'no-store',
|
|
651
|
+
'x-content-type-options': 'nosniff',
|
|
652
|
+
}
|
|
653
|
+
const host = String(req.headers?.host || '')
|
|
654
|
+
const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
|
|
655
|
+
const local = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
|
656
|
+
if (!local) {
|
|
657
|
+
res.writeHead(403, headers)
|
|
658
|
+
res.end(renderInfoPage('Local only', '<p class="muted">Open this page from the harness computer itself.</p>'))
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const code = published.code || 'starting…'
|
|
663
|
+
const urls = published.urls.length > 0 ? published.urls : []
|
|
664
|
+
const items = urls.length > 0
|
|
665
|
+
? urls.map((url) => `<li><a href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${escapeHtml(url)}</a></li>`).join('')
|
|
666
|
+
: '<li class="muted">starting…</li>'
|
|
667
|
+
const body = `
|
|
668
|
+
<p class="muted">Open one of these addresses on your phone or another computer, then enter the pairing code.</p>
|
|
669
|
+
<ul class="urls">${items}</ul>
|
|
670
|
+
<div class="code" aria-label="Pairing code">${escapeHtml(code)}</div>
|
|
671
|
+
<p class="muted small">The code is stored in <code>remote-control.json</code> inside your DSH home directory. Deleting that file rotates the code and forgets every paired device.</p>`
|
|
672
|
+
res.writeHead(200, headers)
|
|
673
|
+
res.end(renderInfoPage('Remote Control', body, code))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function renderInfoPage(title, body) {
|
|
677
|
+
return `<!doctype html>
|
|
678
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
679
|
+
<title>${escapeHtml(title)}</title>
|
|
680
|
+
<style>
|
|
681
|
+
:root { color-scheme: dark light; --bg:#0b0d12; --fg:#e8ecf4; --muted:#93a0b5; --line:#232936; --accent:#5b8bff; }
|
|
682
|
+
@media (prefers-color-scheme: light) { :root { --bg:#f5f7fb; --fg:#14181f; --muted:#5d6779; --line:#dde2ec; --accent:#2f6bff; } }
|
|
683
|
+
body { margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg); color:var(--fg);
|
|
684
|
+
font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; padding:24px; }
|
|
685
|
+
.card { width:100%; max-width:520px; border:1px solid var(--line); border-radius:18px; padding:24px; background:color-mix(in srgb, var(--fg) 3%, var(--bg)); }
|
|
686
|
+
h1 { font-size:18px; margin:0 0 12px; }
|
|
687
|
+
.muted { color:var(--muted); }
|
|
688
|
+
.small { font-size:13px; margin-top:18px; }
|
|
689
|
+
.urls { padding-left:18px; margin:12px 0 18px; }
|
|
690
|
+
.urls a { color:var(--accent); word-break:break-all; }
|
|
691
|
+
.code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:34px; letter-spacing:.28em;
|
|
692
|
+
text-align:center; padding:14px; border:1px dashed var(--line); border-radius:14px; }
|
|
693
|
+
code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
694
|
+
</style></head>
|
|
695
|
+
<body><div class="card"><h1>${escapeHtml(title)}</h1>${body}</div></body></html>`
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function escapeHtml(value) {
|
|
699
|
+
return String(value).replace(/[&<>"']/g, (character) => ({
|
|
700
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
701
|
+
}[character]))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* ------------------------------------------------------------------ */
|
|
705
|
+
/* configuration */
|
|
706
|
+
/* ------------------------------------------------------------------ */
|
|
707
|
+
|
|
708
|
+
function normalizeConfig(raw = {}) {
|
|
709
|
+
const config = {
|
|
710
|
+
host: typeof raw?.host === 'string' && raw.host.trim() ? raw.host.trim() : '0.0.0.0',
|
|
711
|
+
port: Number.isInteger(raw?.port) ? raw.port : 57330,
|
|
712
|
+
publicUrl: typeof raw?.publicUrl === 'string' ? raw.publicUrl.trim() : '',
|
|
713
|
+
trustedHosts: Array.isArray(raw?.trustedHosts) ? raw.trustedHosts.filter((entry) => typeof entry === 'string') : [],
|
|
714
|
+
code: typeof raw?.code === 'string' ? raw.code.trim() : '',
|
|
715
|
+
cookieDays: Number.isFinite(raw?.cookieDays) ? raw.cookieDays : 30,
|
|
716
|
+
defaultCwd: typeof raw?.defaultCwd === 'string' ? raw.defaultCwd.trim() : '',
|
|
717
|
+
discovery: raw?.discovery !== false,
|
|
718
|
+
discoveryPort: Number.isInteger(raw?.discoveryPort) ? raw.discoveryPort : 57331,
|
|
719
|
+
relayUrl: typeof raw?.relayUrl === 'string' ? raw.relayUrl.trim() : '',
|
|
720
|
+
relayKey: typeof raw?.relayKey === 'string' ? raw.relayKey : '',
|
|
721
|
+
deviceName: typeof raw?.deviceName === 'string' && raw.deviceName.trim() ? raw.deviceName.trim() : os.hostname(),
|
|
722
|
+
tls: raw?.tls && typeof raw.tls === 'object' ? { certFile: raw.tls.certFile, keyFile: raw.tls.keyFile } : null,
|
|
723
|
+
}
|
|
724
|
+
if (!(config.port >= 0 && config.port <= 65535)) throw new Error('remote-control: `port` must be 0-65535')
|
|
725
|
+
if (!(config.discoveryPort >= 1 && config.discoveryPort <= 65535)) throw new Error('remote-control: `discoveryPort` must be 1-65535')
|
|
726
|
+
if (config.code && !/^\d{4,12}$/.test(config.code)) throw new Error('remote-control: `code` must be 4-12 digits')
|
|
727
|
+
if (config.cookieDays <= 0) throw new Error('remote-control: `cookieDays` must be positive')
|
|
728
|
+
if (config.tls && (!config.tls.certFile || !config.tls.keyFile)) throw new Error('remote-control: `tls` needs both certFile and keyFile')
|
|
729
|
+
if (config.publicUrl) {
|
|
730
|
+
try {
|
|
731
|
+
new URL(config.publicUrl)
|
|
732
|
+
} catch {
|
|
733
|
+
throw new Error('remote-control: `publicUrl` must be an absolute http(s) URL')
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (config.relayUrl) {
|
|
737
|
+
let parsed
|
|
738
|
+
try {
|
|
739
|
+
parsed = new URL(config.relayUrl)
|
|
740
|
+
} catch {
|
|
741
|
+
throw new Error('remote-control: `relayUrl` must be an absolute http(s) URL')
|
|
742
|
+
}
|
|
743
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
744
|
+
throw new Error('remote-control: `relayUrl` must use http or https')
|
|
745
|
+
}
|
|
746
|
+
if (!config.relayKey || config.relayKey.length < 16) {
|
|
747
|
+
throw new Error('remote-control: `relayKey` must be at least 16 characters when `relayUrl` is set')
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return config
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function dshHome() {
|
|
754
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function loadState(file, config, log) {
|
|
758
|
+
let persisted
|
|
759
|
+
try {
|
|
760
|
+
persisted = JSON.parse(await readFile(file, 'utf8'))
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
|
|
763
|
+
}
|
|
764
|
+
if (persisted && typeof persisted.secret === 'string' && persisted.secret.length >= 32) {
|
|
765
|
+
const state = {
|
|
766
|
+
secret: persisted.secret,
|
|
767
|
+
code: config.code || (typeof persisted.code === 'string' ? persisted.code : generateCode()),
|
|
768
|
+
createdAt: persisted.createdAt ?? Date.now(),
|
|
769
|
+
deviceId: typeof persisted.deviceId === 'string' && persisted.deviceId ? persisted.deviceId : newDeviceId(),
|
|
770
|
+
}
|
|
771
|
+
if (state.deviceId !== persisted.deviceId) await persistState(file, state, log)
|
|
772
|
+
return state
|
|
773
|
+
}
|
|
774
|
+
const state = {
|
|
775
|
+
secret: randomBytes(32).toString('hex'),
|
|
776
|
+
code: config.code || generateCode(),
|
|
777
|
+
createdAt: Date.now(),
|
|
778
|
+
deviceId: newDeviceId(),
|
|
779
|
+
}
|
|
780
|
+
await persistState(file, state, log)
|
|
781
|
+
return state
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function persistState(file, state, log) {
|
|
785
|
+
try {
|
|
786
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
787
|
+
await writeFile(file, JSON.stringify(state, null, 2), { mode: 0o600 })
|
|
788
|
+
} catch (error) {
|
|
789
|
+
log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function newDeviceId() {
|
|
794
|
+
return `dsh-${randomBytes(8).toString('hex')}`
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function generateCode() {
|
|
798
|
+
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function timeZone() {
|
|
802
|
+
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return undefined }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function firstString(...candidates) {
|
|
806
|
+
for (const candidate of candidates) {
|
|
807
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim()
|
|
808
|
+
}
|
|
809
|
+
return ''
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function pickLogger(ctx) {
|
|
813
|
+
const candidate = typeof ctx?.logger === 'function' ? safely(() => ctx.logger('remote-control')) : ctx?.logger
|
|
814
|
+
if (candidate && typeof candidate.info === 'function') return candidate
|
|
815
|
+
return console
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function safely(fn) {
|
|
819
|
+
try { return fn() } catch { return undefined }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/* ------------------------------------------------------------------ */
|
|
823
|
+
/* authentication */
|
|
824
|
+
/* ------------------------------------------------------------------ */
|
|
825
|
+
|
|
826
|
+
function parseCookies(header) {
|
|
827
|
+
const jar = {}
|
|
828
|
+
if (typeof header !== 'string') return jar
|
|
829
|
+
for (const part of header.split(';')) {
|
|
830
|
+
const index = part.indexOf('=')
|
|
831
|
+
if (index <= 0) continue
|
|
832
|
+
jar[part.slice(0, index).trim()] = part.slice(index + 1).trim()
|
|
833
|
+
}
|
|
834
|
+
return jar
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function hmac(secret, value) {
|
|
838
|
+
return createHmac('sha256', secret).update(value).digest('hex')
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function issueToken(secret, days) {
|
|
842
|
+
const expires = Date.now() + Math.max(1, days) * 86_400_000
|
|
843
|
+
return `${expires}.${hmac(secret, String(expires))}`
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function isAuthenticated(req, secret) {
|
|
847
|
+
const raw = parseCookies(req.headers?.cookie)[COOKIE_NAME]
|
|
848
|
+
if (!raw) return false
|
|
849
|
+
const dot = raw.lastIndexOf('.')
|
|
850
|
+
if (dot <= 0) return false
|
|
851
|
+
const expires = raw.slice(0, dot)
|
|
852
|
+
const signature = raw.slice(dot + 1)
|
|
853
|
+
if (!/^\d{1,16}$/.test(expires) || Number(expires) < Date.now()) return false
|
|
854
|
+
const expected = Buffer.from(hmac(secret, expires), 'hex')
|
|
855
|
+
const actual = Buffer.from(signature, 'hex')
|
|
856
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function constantTimeEquals(value, expected) {
|
|
860
|
+
const left = Buffer.from(String(value))
|
|
861
|
+
const right = Buffer.from(String(expected))
|
|
862
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const attempts = new Map()
|
|
866
|
+
|
|
867
|
+
function clientIp(req) {
|
|
868
|
+
return String(req.socket?.remoteAddress || 'unknown')
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function tooManyAttempts(ip) {
|
|
872
|
+
const entry = attempts.get(ip)
|
|
873
|
+
if (!entry) return false
|
|
874
|
+
if (entry.resetAt < Date.now()) { attempts.delete(ip); return false }
|
|
875
|
+
return entry.failures >= 10
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function recordFailure(ip) {
|
|
879
|
+
const now = Date.now()
|
|
880
|
+
const entry = attempts.get(ip)
|
|
881
|
+
if (!entry || entry.resetAt < now) {
|
|
882
|
+
attempts.set(ip, { failures: 1, resetAt: now + 10 * 60_000 })
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
entry.failures += 1
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function recordSuccess(ip) {
|
|
889
|
+
attempts.delete(ip)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/* ------------------------------------------------------------------ */
|
|
893
|
+
/* request trust */
|
|
894
|
+
/* ------------------------------------------------------------------ */
|
|
895
|
+
|
|
896
|
+
function hostAllowed(header, config) {
|
|
897
|
+
const host = String(header || '').trim()
|
|
898
|
+
if (!host) return false
|
|
899
|
+
const hostname = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
|
|
900
|
+
const name = hostname.toLowerCase()
|
|
901
|
+
if (!name) return false
|
|
902
|
+
if (name === 'localhost') return true
|
|
903
|
+
if (config.publicUrl) {
|
|
904
|
+
try {
|
|
905
|
+
if (new URL(config.publicUrl).hostname.toLowerCase() === name) return true
|
|
906
|
+
} catch { /* ignore */ }
|
|
907
|
+
}
|
|
908
|
+
if (config.trustedHosts.some((entry) => entry.toLowerCase() === name || entry.toLowerCase() === host.toLowerCase())) return true
|
|
909
|
+
if (isIP(name)) {
|
|
910
|
+
if (name === '127.0.0.1' || name === '::1') return true
|
|
911
|
+
return isWildcard(config.host) || normalizeAddress(name) === normalizeAddress(config.host)
|
|
912
|
+
}
|
|
913
|
+
return false
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function sameOrigin(req, config) {
|
|
917
|
+
const origin = req.headers?.origin
|
|
918
|
+
if (!origin || origin === 'null') return true
|
|
919
|
+
try {
|
|
920
|
+
const parsed = new URL(origin)
|
|
921
|
+
const host = String(req.headers.host || '').toLowerCase()
|
|
922
|
+
if (parsed.host.toLowerCase() === host) return true
|
|
923
|
+
if (config.publicUrl && parsed.host.toLowerCase() === new URL(config.publicUrl).host.toLowerCase()) return true
|
|
924
|
+
return false
|
|
925
|
+
} catch {
|
|
926
|
+
return false
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* The Devices panel talks to this Host half through the GUI's own origin.
|
|
932
|
+
* Same-origin browser fetches are allowed; a non-browser caller must be local.
|
|
933
|
+
*/
|
|
934
|
+
function consoleAllowed(req) {
|
|
935
|
+
const site = req.headers?.['sec-fetch-site']
|
|
936
|
+
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return false
|
|
937
|
+
if (!site && !isLoopbackAddress(req.socket?.remoteAddress)) return false
|
|
938
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
939
|
+
const origin = req.headers?.origin
|
|
940
|
+
if (typeof origin === 'string' && origin && origin !== 'null') {
|
|
941
|
+
try {
|
|
942
|
+
if (new URL(origin).host !== String(req.headers.host || '')) return false
|
|
943
|
+
} catch {
|
|
944
|
+
return false
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return true
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function isLoopbackAddress(address) {
|
|
952
|
+
const value = String(address || '')
|
|
953
|
+
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1'
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function isWildcard(host) {
|
|
957
|
+
return host === '0.0.0.0' || host === '::' || host === '*'
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function normalizeAddress(value) {
|
|
961
|
+
return String(value).toLowerCase().replace(/^\[|\]$/g, '')
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function accessUrls(config, port, scheme) {
|
|
965
|
+
if (config.publicUrl) return [config.publicUrl.replace(/\/+$/, '') + '/']
|
|
966
|
+
if (!isWildcard(config.host)) return [`${scheme}://${formatHost(config.host)}:${port}/`]
|
|
967
|
+
const addresses = []
|
|
968
|
+
for (const entries of Object.values(os.networkInterfaces())) {
|
|
969
|
+
for (const entry of entries ?? []) {
|
|
970
|
+
if (entry.internal) continue
|
|
971
|
+
if (entry.family !== 'IPv4' && entry.family !== 4) continue
|
|
972
|
+
addresses.push(`${scheme}://${entry.address}:${port}/`)
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (addresses.length === 0) addresses.push(`${scheme}://127.0.0.1:${port}/`)
|
|
976
|
+
return addresses
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function formatHost(host) {
|
|
980
|
+
return host.includes(':') ? `[${host}]` : host
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* ------------------------------------------------------------------ */
|
|
984
|
+
/* request helpers */
|
|
985
|
+
/* ------------------------------------------------------------------ */
|
|
986
|
+
|
|
987
|
+
function readJson(req) {
|
|
988
|
+
return new Promise((resolve, reject) => {
|
|
989
|
+
const chunks = []
|
|
990
|
+
let size = 0
|
|
991
|
+
req.on('data', (chunk) => {
|
|
992
|
+
size += chunk.length
|
|
993
|
+
if (size > MAX_BODY_BYTES) {
|
|
994
|
+
reject(new Error('request body too large'))
|
|
995
|
+
req.destroy()
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
chunks.push(chunk)
|
|
999
|
+
})
|
|
1000
|
+
req.on('end', () => {
|
|
1001
|
+
if (chunks.length === 0) return resolve({})
|
|
1002
|
+
try {
|
|
1003
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
reject(error)
|
|
1006
|
+
}
|
|
1007
|
+
})
|
|
1008
|
+
req.on('error', reject)
|
|
1009
|
+
})
|
|
1010
|
+
}
|