@skillpie/dsh-remote 0.0.0-stage → 1.0.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.
@@ -0,0 +1,823 @@
1
+ /**
2
+ * dsh-remote — relay core: code registry, per-code routes, bridging.
3
+ *
4
+ * The same core serves the server-side relay face (on dsh.skillpie.cn) and
5
+ * the local direct mode. A host socket registers with an 8-digit code; the
6
+ * core mounts the public surface for `/<code>` on the given webServer:
7
+ *
8
+ * - `gui` mode (default): transparently mounts the host's real DSH Web GUI
9
+ * under `/<code>/`. HTTP requests are stripped of the prefix, framed over
10
+ * the control channel, and served from the host's own web server over
11
+ * loopback; the GUI's single mux WebSocket (`/api/remote.mux`) is spliced
12
+ * byte-by-byte. Set-cookie lines are rewritten per code (name prefix
13
+ * `r<code>-`, `Path=/` → `Path=/<code>/`) so several hosts can share one
14
+ * relay origin without cookie collisions.
15
+ * - `console` mode: the lightweight visitor console page and its JSON
16
+ * command channel.
17
+ *
18
+ * Registry: hosts keyed by hostId, codes kept unique; tokens stored hashed.
19
+ * A host reconnecting with the correct token supersedes its stale socket;
20
+ * an unknown hostId claims its id fresh (fail-closed, matching the built-in
21
+ * relay's design) and receives a newly minted token.
22
+ */
23
+ import { randomBytes, randomUUID } from 'node:crypto'
24
+ import { WebSocketServer } from 'ws'
25
+ import {
26
+ PROTOCOL_VERSION,
27
+ isValidCode,
28
+ hashToken,
29
+ parseFrame,
30
+ VISITOR_COMMANDS,
31
+ encodeBin,
32
+ decodeBin,
33
+ } from './protocol.js'
34
+ import { adaptSocket } from './ws-adapter.js'
35
+
36
+ const HELLO_TIMEOUT_MS = 5_000
37
+ const OFFLINE_GRACE_MS = 10 * 60_000
38
+ const VISITOR_IDLE_MS = 90_000
39
+ const HTTP_OP_TIMEOUT_MS = 150_000
40
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
41
+ /**
42
+ * Inbound caps. Host frames carry whole GUI responses as binary chunks
43
+ * (capped at 8 MiB each by the sender); visitor frames carry POST bodies in
44
+ * the same chunking, so the visitor cap stays generous too. Commands in
45
+ * console mode are tiny and checked separately.
46
+ */
47
+ const MAX_HOST_FRAME_BYTES = 8 * 1024 * 1024
48
+ const MAX_HTML_INJECT_BYTES = 4 * 1024 * 1024
49
+ const MAX_VISITOR_FRAME_BYTES = 8 * 1024 * 1024
50
+
51
+ export class RelayCore {
52
+ constructor({ webServer, consoleHtml, log = () => {}, maxVisitorsPerHost = 8, mode = 'console' }) {
53
+ this.webServer = webServer
54
+ this.consoleHtml = consoleHtml
55
+ this.log = log
56
+ this.maxVisitorsPerHost = maxVisitorsPerHost
57
+ this.mode = mode === 'gui' ? 'gui' : 'console'
58
+ /** hostId → host record */
59
+ this.hosts = new Map()
60
+ /** code → hostId */
61
+ this.codeIndex = new Map()
62
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_VISITOR_FRAME_BYTES })
63
+ this.hostWss = new WebSocketServer({ noServer: true, maxPayload: MAX_HOST_FRAME_BYTES })
64
+ this.cmdSeq = 0
65
+ this.timers = new Set()
66
+ this.closed = false
67
+ }
68
+
69
+ later(fn, ms) {
70
+ const timer = setTimeout(() => {
71
+ this.timers.delete(timer)
72
+ if (!this.closed) fn()
73
+ }, ms)
74
+ this.timers.add(timer)
75
+ return timer
76
+ }
77
+
78
+ disposeTimer(timer) {
79
+ clearTimeout(timer)
80
+ this.timers.delete(timer)
81
+ }
82
+
83
+ /** webServer upgrade handler for the host control channel. */
84
+ acceptHostUpgrade(req, rawSocket, head) {
85
+ if (this.closed) { rawSocket.destroy(); return }
86
+ try {
87
+ this.hostWss.handleUpgrade(req, rawSocket, head, (raw) => {
88
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
89
+ ? req.headers['x-forwarded-for'].split(',')[0].trim()
90
+ : ''
91
+ this.acceptHostSocket(adaptSocket(raw), forwarded)
92
+ })
93
+ } catch (error) {
94
+ this.log('relay', `宿主升级失败(已隔离): ${error?.message ?? error}`)
95
+ try { rawSocket.destroy() } catch { /* gone */ }
96
+ }
97
+ }
98
+
99
+ /** One host control frame, isolated from socket-listener exceptions. */
100
+ onHostFrame(state, socket, data, fail, ip) {
101
+ if (typeof data !== 'string') {
102
+ // Binary: GUI splice payload (response chunk / ws bytes).
103
+ if (!state.hostId) return
104
+ const host = this.hosts.get(state.hostId)
105
+ if (!host) return
106
+ host.lastSeen = Date.now()
107
+ const decoded = decodeBin(data)
108
+ if (!decoded) return
109
+ this.routeBin(host, decoded.meta, decoded.payload)
110
+ return
111
+ }
112
+ const frame = parseFrame(data, MAX_HOST_FRAME_BYTES)
113
+ if (!frame) {
114
+ if (data.length > MAX_HOST_FRAME_BYTES) {
115
+ this.log('relay', `dropped oversized host frame (${Math.round(data.length / 1024)} KiB)`)
116
+ }
117
+ return
118
+ }
119
+ if (frame.t === 'hello') {
120
+ this.disposeTimer(state.helloTimer)
121
+ const registeredId = this.registerHost(socket, frame, state.ip ?? '', fail)
122
+ if (registeredId) state.hostId = registeredId
123
+ return
124
+ }
125
+ if (!state.hostId) return
126
+ const host = this.hosts.get(state.hostId)
127
+ if (!host) return
128
+ host.lastSeen = Date.now()
129
+ if (frame.t === 'hb') {
130
+ socket.send({ t: 'hb' })
131
+ return
132
+ }
133
+ if (frame.t === 'res') this.routeResult(host, frame)
134
+ else if (frame.t === 'push') this.routePush(host, frame)
135
+ else if (frame.t === 'hres' || frame.t === 'hfail') this.routeHttp(host, frame)
136
+ else if (frame.t === 'hend') this.finishHttp(host, frame.id)
137
+ else if (frame.t === 'wok' || frame.t === 'wfail') this.routeSpliceControl(host, frame)
138
+ else if (frame.t === 'wclose') this.finishSplice(host, frame)
139
+ else if (frame.t === 'wdata-text') this.routeText(host, frame)
140
+ }
141
+
142
+ /** Accept one host end (already adapted: pipe end or ws adapter). */
143
+ acceptHostSocket(socket, ip = '') {
144
+ const state = { hostId: undefined, helloTimer: undefined, ip }
145
+ const fail = (error) => {
146
+ socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: false, error })
147
+ this.later(() => socket.close(), 150)
148
+ }
149
+ state.helloTimer = this.later(() => {
150
+ if (!state.hostId) fail('hello timeout')
151
+ }, HELLO_TIMEOUT_MS)
152
+ const offMessage = socket.onMessage((data) => {
153
+ try {
154
+ this.onHostFrame(state, socket, data, fail, state.ip)
155
+ } catch (error) {
156
+ this.log('relay', `宿主机帧处理异常(已隔离): ${error?.message ?? error}`)
157
+ }
158
+ })
159
+ const offClose = socket.onClose(() => {
160
+ this.disposeTimer(state.helloTimer)
161
+ offMessage()
162
+ offClose()
163
+ if (!state.hostId) return
164
+ const host = this.hosts.get(state.hostId)
165
+ if (!host || host.socket !== socket) return
166
+ this.log('relay', `host offline: ${host.name} (${host.code})`)
167
+ host.socket = undefined
168
+ for (const visitor of host.visitors.values()) visitor.socket.close()
169
+ host.visitors.clear()
170
+ host.pending.clear()
171
+ for (const op of host.httpOps.values()) {
172
+ this.disposeTimer(op.timer)
173
+ try { op.res.destroy() } catch { op.res.end() }
174
+ }
175
+ host.httpOps.clear()
176
+ for (const splice of host.splices.values()) {
177
+ try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
178
+ }
179
+ host.splices.clear()
180
+ // Keep the routes briefly so a reconnecting host reclaims them; a host
181
+ // that stays away loses the registration after the grace window.
182
+ host.offlineTimer = this.later(() => this.removeHost(host.hostId, 'offline grace elapsed'), OFFLINE_GRACE_MS)
183
+ })
184
+ }
185
+
186
+ registerHost(socket, hello, ip, fail) {
187
+ const hostId = typeof hello.hostId === 'string' ? hello.hostId.slice(0, 128) : ''
188
+ const code = isValidCode(hello.code) ? hello.code : null
189
+ if (!hostId || !code) return fail('invalid hello')
190
+ const existing = this.hosts.get(hostId)
191
+ const token = typeof hello.token === 'string' ? hello.token : ''
192
+ if (existing) {
193
+ if (token !== '' && hashToken(token) !== existing.tokenHash) {
194
+ this.log('relay', `rejected host ${hostId}: token mismatch`)
195
+ return fail('hostId already registered with a different token')
196
+ }
197
+ if (token === '' && existing.tokenHash !== '') {
198
+ this.log('relay', `rejected host ${hostId}: empty token for known identity`)
199
+ return fail('hostId already registered; token required')
200
+ }
201
+ }
202
+ // Code anti-hijack: a live code bound to another host id never rebinds.
203
+ const codeOwner = this.codeIndex.get(code)
204
+ if (codeOwner !== undefined && codeOwner !== hostId && this.hosts.get(codeOwner)) {
205
+ return fail('code already in use')
206
+ }
207
+ const minted = token === '' || existing?.tokenHash !== hashToken(token)
208
+ const newToken = minted ? randomBytes(24).toString('hex') : token
209
+ if (existing?.offlineTimer) this.disposeTimer(existing.offlineTimer)
210
+ if (existing?.socket && existing.socket !== socket) existing.socket.close()
211
+ if (codeOwner !== undefined && codeOwner !== hostId) {
212
+ // Old owner is gone (removeHost clears the index only for its own code;
213
+ // reaching here means a stale index entry) — reclaim defensively.
214
+ this.codeIndex.delete(code)
215
+ }
216
+ const host = existing ?? {
217
+ hostId,
218
+ visitors: new Map(),
219
+ pending: new Map(),
220
+ httpOps: new Map(),
221
+ splices: new Map(),
222
+ routes: [],
223
+ createdAt: Date.now(),
224
+ }
225
+ if (existing) {
226
+ for (const route of host.routes) route()
227
+ host.routes = []
228
+ }
229
+ const previousCode = host.code
230
+ host.code = code
231
+ host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
232
+ host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
233
+ host.ip = ip
234
+ host.tokenHash = hashToken(newToken)
235
+ host.socket = socket
236
+ host.lastSeen = Date.now()
237
+ this.hosts.set(hostId, host)
238
+ this.codeIndex.set(code, hostId)
239
+ if (previousCode && previousCode !== code && this.codeIndex.get(previousCode) === hostId) {
240
+ this.codeIndex.delete(previousCode)
241
+ }
242
+ // Mount the public surface for this code.
243
+ try {
244
+ if (this.mode === 'gui') {
245
+ host.routes.push(this.webServer.register({
246
+ kind: 'prefix',
247
+ path: `/${code}`,
248
+ handler: (req, res) => {
249
+ try { this.proxyHttp(req, res, code, host) } catch (error) {
250
+ this.log('relay', `代理请求失败(已隔离): ${error?.message ?? error}`)
251
+ try { res.writeHead(500).end() } catch { /* gone */ }
252
+ }
253
+ },
254
+ }))
255
+ host.routes.push(this.webServer.registerUpgrade({
256
+ path: `/${code}/api/remote.mux`,
257
+ handler: (req, upgradeSocket, head) => {
258
+ try { this.acceptWsSplice(req, upgradeSocket, head, host) } catch (error) {
259
+ this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
260
+ try { upgradeSocket.destroy() } catch { /* gone */ }
261
+ }
262
+ },
263
+ }))
264
+ } else {
265
+ host.routes.push(this.webServer.register({
266
+ kind: 'prefix',
267
+ path: `/${code}`,
268
+ handler: (req, res) => {
269
+ try { this.serveConsole(req, res, code, host) } catch (error) {
270
+ this.log('relay', `控制台请求失败(已隔离): ${error?.message ?? error}`)
271
+ try { res.writeHead(500).end() } catch { /* gone */ }
272
+ }
273
+ },
274
+ }))
275
+ host.routes.push(this.webServer.registerUpgrade({
276
+ path: `/${code}/ws`,
277
+ handler: (req, upgradeSocket, head) => {
278
+ try { this.acceptVisitor(req, upgradeSocket, head, host) } catch (error) {
279
+ this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
280
+ try { upgradeSocket.destroy() } catch { /* gone */ }
281
+ }
282
+ },
283
+ }))
284
+ }
285
+ } catch (error) {
286
+ this.log('relay', `route registration failed for ${code}: ${error?.message ?? error}`)
287
+ this.codeIndex.delete(code)
288
+ this.hosts.delete(hostId)
289
+ return fail('route registration failed')
290
+ }
291
+ this.log('relay', `host registered: ${host.name} code=${code} mode=${this.mode}${minted ? ' (token minted)' : ''}`)
292
+ socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: true, token: newToken })
293
+ return hostId
294
+ }
295
+
296
+ removeHost(hostId, reason) {
297
+ const host = this.hosts.get(hostId)
298
+ if (!host) return
299
+ this.hosts.delete(hostId)
300
+ if (this.codeIndex.get(host.code) === hostId) this.codeIndex.delete(host.code)
301
+ if (host.offlineTimer) this.disposeTimer(host.offlineTimer)
302
+ for (const route of host.routes) route()
303
+ host.routes = []
304
+ host.socket?.close()
305
+ for (const visitor of host.visitors.values()) visitor.socket.close()
306
+ host.visitors.clear()
307
+ host.pending.clear()
308
+ for (const op of host.httpOps.values()) {
309
+ this.disposeTimer(op.timer)
310
+ try { op.res.destroy() } catch { op.res.end() }
311
+ }
312
+ host.httpOps.clear()
313
+ for (const splice of host.splices.values()) {
314
+ try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
315
+ }
316
+ host.splices.clear()
317
+ this.log('relay', `host removed: ${host.name} (${host.code}) — ${reason}`)
318
+ }
319
+
320
+ /* ── gui mode: transparent HTTP + WS splice ─────────────────────────── */
321
+
322
+ stripPrefix(pathname, code) {
323
+ if (pathname === `/${code}`) return '/'
324
+ return pathname.slice(`/${code}`.length) || '/'
325
+ }
326
+
327
+ /** Strip the per-code prefix from visitor cookie names. */
328
+ stripCodeCookies(header, code) {
329
+ if (typeof header !== 'string' || header === '') return undefined
330
+ const prefix = `r${code}-`
331
+ const kept = []
332
+ for (const segment of header.split(';')) {
333
+ const pair = segment.trim()
334
+ if (!pair) continue
335
+ const at = pair.indexOf('=')
336
+ if (at === -1) continue
337
+ const name = pair.slice(0, at)
338
+ if (!name.startsWith(prefix)) continue
339
+ kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
340
+ }
341
+ return kept.length > 0 ? kept.join('; ') : undefined
342
+ }
343
+
344
+ /** Prefix visitor cookie names and scope Path to the code mount. */
345
+ rewriteSetCookie(line, code) {
346
+ const prefix = `r${code}-`
347
+ const at = line.indexOf('=')
348
+ if (at === -1) return line
349
+ let out = `${prefix}${line.slice(0, at)}=${line.slice(at + 1)}`
350
+ out = out.replace(/Path=\/(?![^\s;])/i, `Path=/${code}/`)
351
+ return out
352
+ }
353
+
354
+ offlineHtml(code) {
355
+ return `<!DOCTYPE html>
356
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>try{localStorage.setItem('dsh-remote-last-code','${code}')}catch(e){}</script>
357
+ <meta name="viewport" content="width=device-width, initial-scale=1">
358
+ <title>远程控制 ${code}</title>
359
+ <style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#101114;color:#e8eaed;font:14px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.big{font-size:17px}.hint{font-size:12.5px;color:#9aa0a6}code{font-family:ui-monospace,Menlo,monospace;color:#6b84ff}</style></head>
360
+ <body><div class="big">宿主机暂时离线</div><div class="hint">远程码 <code>${code}</code> 有效,但宿主机尚未连上中转站。页面会自动重试。</div></body></html>`
361
+ }
362
+
363
+ /**
364
+ * Visitor-side URL shim. The proxied GUI and plugin panels call absolute
365
+ * same-origin paths (`/api/...`, `/dsh-dba/...`), which only resolve on the
366
+ * host's own origin. Under `/<code>/` they must ride the prefix so they land
367
+ * on the relay's proxy route and splice mux instead of the SPA fallback.
368
+ * Injected into proxied HTML right after `<head>`; runs before app scripts.
369
+ */
370
+ visitorShimScript(code) {
371
+ const prefix = JSON.stringify(`/${code}`)
372
+ const map = JSON.stringify(['/api/', '/dsh-dba/', '/dsh-market/'])
373
+ return `<script>(function(){
374
+ window.__DSH_REMOTE_VISITOR__=true;
375
+ try{localStorage.setItem('dsh-remote-last-code',${JSON.stringify(code)})}catch(e){}
376
+ var P=${prefix};var M=${map};
377
+ function pick(p){for(var i=0;i<M.length;i++){var m=M[i];if(p===m||p.slice(0,m.length)===m)return true}return false}
378
+ function rebase(u){try{var x=new URL(u,location.href);if((x.protocol==='http:'||x.protocol==='https:'||x.protocol==='ws:'||x.protocol==='wss:')&&x.host===location.host&&pick(x.pathname)){return P+x.pathname+x.search+x.hash}}catch(e){}return u}
379
+ var of=window.fetch;
380
+ window.fetch=function(input,init){
381
+ try{
382
+ if(typeof input==='string')return of(rebase(input),init);
383
+ if(input&&typeof input.url==='string'){var nu=rebase(input.url);if(nu!==input.url)return of(new Request(nu,input),init)}
384
+ }catch(e){}
385
+ return of(input,init);
386
+ };
387
+ var OW=window.WebSocket;
388
+ window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?u:String(u));super(u,p)}};
389
+ })(/* dsh-remote visitor shim */);</script>`
390
+ }
391
+
392
+ injectVisitorShim(html, code) {
393
+ if (typeof html !== 'string' || html.length === 0) return html
394
+ const shim = this.visitorShimScript(code)
395
+ if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + shim)
396
+ return shim + html
397
+ }
398
+
399
+ proxyHttp(req, res, code, host) {
400
+ let pathname = '/'
401
+ let search = ''
402
+ try {
403
+ const url = new URL(req.url ?? '/', 'http://localhost')
404
+ pathname = url.pathname
405
+ search = url.search
406
+ } catch { pathname = '/' }
407
+ const subpath = this.stripPrefix(pathname, code)
408
+ const targetPath = subpath + search
409
+ const send = (status, body, headers = {}) => {
410
+ if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
411
+ res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers })
412
+ res.end(body)
413
+ }
414
+ if (pathname === `/${code}` && (req.method === 'GET' || req.method === 'HEAD')) {
415
+ res.writeHead(302, { location: `/${code}/`, 'cache-control': 'no-store' })
416
+ res.end()
417
+ return
418
+ }
419
+ if (!host.socket) {
420
+ if (subpath === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
421
+ send(200, this.offlineHtml(code), { 'Content-Type': 'text/html; charset=utf-8' })
422
+ } else {
423
+ send(504, '宿主机离线\n', { 'Content-Type': 'text/plain; charset=utf-8' })
424
+ }
425
+ return
426
+ }
427
+ if (host.httpOps.size >= 64) {
428
+ send(429, 'too many in-flight requests\n', { 'Content-Type': 'text/plain; charset=utf-8' })
429
+ return
430
+ }
431
+ const id = `h${++this.cmdSeq}`
432
+ // Buffer the request body (GUI uploads included), then frame it over.
433
+ const chunks = []
434
+ let size = 0
435
+ let done = false
436
+ let failed = false
437
+ req.on('data', (chunk) => {
438
+ size += chunk.length
439
+ if (size > MAX_REQUEST_BODY_BYTES) {
440
+ if (!failed) { failed = true; send(413, 'payload too large\n', { 'Content-Type': 'text/plain; charset=utf-8' }) }
441
+ req.destroy()
442
+ return
443
+ }
444
+ chunks.push(chunk)
445
+ })
446
+ req.on('error', () => { failed = true })
447
+ req.on('end', () => { done = true })
448
+ const op = { res, timer: undefined, done: false, method: req.method }
449
+ host.httpOps.set(id, op)
450
+ op.timer = this.later(() => {
451
+ if (!op.done) {
452
+ host.httpOps.delete(id)
453
+ send(504, '宿主机响应超时\n', { 'Content-Type': 'text/plain; charset=utf-8' })
454
+ }
455
+ }, HTTP_OP_TIMEOUT_MS)
456
+ res.on('close', () => {
457
+ if (!op.done && host.socket) host.socket.send({ t: 'habort', id })
458
+ })
459
+ const dispatch = () => {
460
+ if (failed || this.closed) { host.httpOps.delete(id); return }
461
+ const body = Buffer.concat(chunks)
462
+ host.socket.send({
463
+ t: 'hreq',
464
+ id,
465
+ m: req.method,
466
+ p: targetPath,
467
+ h: {
468
+ ...sanitize(req.headers, code),
469
+ ...(body.length > 0 ? { 'content-length': String(body.length) } : {}),
470
+ },
471
+ })
472
+ if (body.length > 0) host.socket.sendRaw(encodeBin({ t: 'hchunk', id }, body))
473
+ host.socket.send({ t: 'hend', id })
474
+ }
475
+ if (done) dispatch()
476
+ else req.on('end', () => dispatch())
477
+
478
+ function sanitize(headers, cd) {
479
+ const out = {}
480
+ const drop = new Set(['host', 'origin', 'referer', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'accept-encoding', 'content-length'])
481
+ for (const [name, value] of Object.entries(headers)) {
482
+ const lower = name.toLowerCase()
483
+ if (drop.has(lower) || lower.startsWith('sec-')) continue
484
+ if (lower === 'cookie') {
485
+ const stripped = stripCookiesHeader(value, cd)
486
+ if (stripped) out.cookie = stripped
487
+ continue
488
+ }
489
+ out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
490
+ }
491
+ return out
492
+ }
493
+ function stripCookiesHeader(header, cd) {
494
+ if (typeof header !== 'string' || header === '') return undefined
495
+ const prefix = `r${cd}-`
496
+ const kept = []
497
+ for (const segment of header.split(';')) {
498
+ const pair = segment.trim()
499
+ const at = pair.indexOf('=')
500
+ if (at === -1) continue
501
+ const name = pair.slice(0, at)
502
+ if (!name.startsWith(prefix)) continue
503
+ kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
504
+ }
505
+ return kept.length > 0 ? kept.join('; ') : undefined
506
+ }
507
+ }
508
+
509
+ routeHttp(host, frame) {
510
+ const op = host.httpOps.get(frame.id)
511
+ if (!op) return
512
+ if (frame.t === 'hfail') {
513
+ host.httpOps.delete(frame.id)
514
+ this.disposeTimer(op.timer)
515
+ op.done = true
516
+ if (!op.res.headersSent) {
517
+ op.res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
518
+ op.res.end(`宿主机桥接失败: ${frame.error ?? 'unknown'}\n`)
519
+ } else {
520
+ try { op.res.destroy() } catch { op.res.end() }
521
+ }
522
+ return
523
+ }
524
+ const headers = { ...(frame.h ?? {}) }
525
+ const setCookies = Array.isArray(frame.sc) ? frame.sc.map((line) => this.rewriteSetCookie(line, host.code)) : []
526
+ if (setCookies.length > 0) headers['set-cookie'] = setCookies
527
+ // HTML 响应缓冲注入访客 shim(GET/HEAD 才有 body;上限见 MAX_HTML_INJECT_BYTES)。
528
+ if (
529
+ (op.method === 'GET' || op.method === 'HEAD') &&
530
+ /text\/html/i.test(String(headers['content-type'] ?? ''))
531
+ ) {
532
+ op.htmlInject = true
533
+ op.parts = []
534
+ op.htmlBytes = 0
535
+ }
536
+ try {
537
+ op.res.writeHead(frame.s ?? 502, headers)
538
+ } catch (error) {
539
+ this.log('relay', `bad upstream response for ${host.code}: ${error?.message ?? error}`)
540
+ host.httpOps.delete(frame.id)
541
+ this.disposeTimer(op.timer)
542
+ op.done = true
543
+ try { op.res.destroy() } catch { op.res.end() }
544
+ }
545
+ }
546
+
547
+ finishHttp(host, id) {
548
+ const op = host.httpOps.get(id)
549
+ if (!op) return
550
+ host.httpOps.delete(id)
551
+ this.disposeTimer(op.timer)
552
+ op.done = true
553
+ try {
554
+ if (op.htmlInject) {
555
+ const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code)
556
+ op.res.end(html)
557
+ } else {
558
+ op.res.end()
559
+ }
560
+ } catch { op.res.destroy() }
561
+ }
562
+
563
+ routeText(host, frame) {
564
+ if (frame.t !== 'wdata-text' || typeof frame.text !== 'string') return
565
+ const splice = host.splices.get(frame.id)
566
+ // The visitor socket is a WebSocket: send a ws text frame, never raw bytes.
567
+ if (splice && !splice.closed) splice.socket.sendText(frame.text)
568
+ }
569
+
570
+ routeBin(host, meta, payload) {
571
+ if (meta.t === 'hchunk') {
572
+ const op = host.httpOps.get(meta.id)
573
+ if (!op || op.res.writableEnded) return
574
+ if (op.htmlInject) {
575
+ op.htmlBytes += payload.length
576
+ if (op.htmlBytes <= MAX_HTML_INJECT_BYTES) {
577
+ op.parts.push(payload)
578
+ return
579
+ }
580
+ // 超限放弃注入:先冲掉已缓冲内容再直写当前块,保持字节顺序。
581
+ op.htmlInject = false
582
+ for (const part of op.parts) op.res.write(part)
583
+ op.parts = []
584
+ }
585
+ op.res.write(payload)
586
+ return
587
+ }
588
+ if (meta.t === 'wdata') {
589
+ const splice = host.splices.get(meta.id)
590
+ // The visitor socket is a WebSocket: send a ws binary frame, never raw bytes.
591
+ if (splice && !splice.closed) splice.socket.sendRaw(payload)
592
+ }
593
+ }
594
+
595
+ acceptWsSplice(req, rawSocket, head, host) {
596
+ if (this.closed || !host) { rawSocket.destroy(); return }
597
+ const origin = req.headers.origin
598
+ if (typeof origin === 'string' && origin !== '') {
599
+ let sameOrigin = false
600
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
601
+ if (!sameOrigin) { rawSocket.destroy(); return }
602
+ }
603
+ if (host.splices.size >= this.maxVisitorsPerHost) {
604
+ this.log('relay', `splice rejected on ${host.code}: visitor cap`)
605
+ rawSocket.destroy()
606
+ return
607
+ }
608
+ let target = '/api/remote.mux'
609
+ try {
610
+ const url = new URL(req.url ?? '/', 'http://localhost')
611
+ target = this.stripPrefix(url.pathname, host.code) + url.search
612
+ } catch { target = '/api/remote.mux' }
613
+ const subpath = target
614
+ const id = `s${++this.cmdSeq}`
615
+ const splicedHeaders = sanitizeUpgradeHeaders(req.headers, host.code)
616
+ function sanitizeUpgradeHeaders(headers, cd) {
617
+ const out = {}
618
+ const drop = new Set(['host', 'origin', 'referer', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol'])
619
+ for (const [name, value] of Object.entries(headers)) {
620
+ const lower = name.toLowerCase()
621
+ if (drop.has(lower) || lower.startsWith('sec-')) continue
622
+ if (lower === 'cookie') {
623
+ const prefix = `r${cd}-`
624
+ const kept = []
625
+ for (const segment of String(value).split(';')) {
626
+ const pair = segment.trim()
627
+ const at = pair.indexOf('=')
628
+ if (at === -1) continue
629
+ const cname = pair.slice(0, at)
630
+ if (!cname.startsWith(prefix)) continue
631
+ kept.push(`${cname.slice(prefix.length)}=${pair.slice(at + 1)}`)
632
+ }
633
+ if (kept.length > 0) out.cookie = kept.join('; ')
634
+ continue
635
+ }
636
+ out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
637
+ }
638
+ return out
639
+ }
640
+ this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
641
+ const socket = adaptSocket(raw)
642
+ const splice = { raw, socket, closed: false }
643
+ host.splices.set(id, splice)
644
+ if (!host.socket) {
645
+ socket.close()
646
+ host.splices.delete(id)
647
+ return
648
+ }
649
+ host.socket.send({ t: 'wopen', id, p: subpath, h: splicedHeaders })
650
+ this.log('relay', `splice ${id} opened on ${host.code}: ${subpath}`)
651
+ const offMessage = socket.onMessage((data) => {
652
+ try {
653
+ if (!host.socket) return
654
+ // The adapter hands through Buffers for binary and strings for text.
655
+ if (Buffer.isBuffer(data)) host.socket.sendRaw(encodeBin({ t: 'wdata', id }, data))
656
+ else host.socket.send({ t: 'wdata-text', id, text: data })
657
+ } catch (error) {
658
+ this.log('relay', `访客帧处理异常(已隔离): ${error?.message ?? error}`)
659
+ }
660
+ })
661
+ const offClose = socket.onClose(() => {
662
+ offMessage()
663
+ offClose()
664
+ if (splice.closed) return
665
+ splice.closed = true
666
+ host.splices.delete(id)
667
+ if (host.socket) host.socket.send({ t: 'wclose', id, code: 1001, reason: '' })
668
+ this.log('relay', `splice ${id} closed on ${host.code}`)
669
+ })
670
+ })
671
+ }
672
+
673
+ routeSpliceControl(host, frame) {
674
+ const splice = host.splices.get(frame.id)
675
+ if (!splice) return
676
+ if (frame.t === 'wfail') {
677
+ splice.closed = true
678
+ host.splices.delete(frame.id)
679
+ try { splice.socket.close(1014, 'upstream failed') } catch { splice.raw.destroy() }
680
+ this.log('relay', `splice ${frame.id} failed: ${frame.error ?? 'unknown'}`)
681
+ }
682
+ // wok needs no relay-side action; splice is already bidirectional.
683
+ }
684
+
685
+ finishSplice(host, frame) {
686
+ const splice = host.splices.get(frame.id)
687
+ if (!splice) return
688
+ splice.closed = true
689
+ host.splices.delete(frame.id)
690
+ try { splice.socket.close(frame.code ?? 1000, frame.reason ?? '') } catch { try { splice.raw.destroy() } catch { /* gone */ } }
691
+ }
692
+
693
+ /* ── console mode ───────────────────────────────────────────────────── */
694
+
695
+ serveConsole(req, res, code, host) {
696
+ let pathname = ''
697
+ try { pathname = new URL(req.url ?? '/', 'http://localhost').pathname } catch { pathname = '/' }
698
+ const send = (status, body, type) => {
699
+ res.writeHead(status, {
700
+ 'Content-Type': type,
701
+ 'Cache-Control': 'no-store',
702
+ 'X-Content-Type-Options': 'nosniff',
703
+ })
704
+ res.end(body)
705
+ }
706
+ if (pathname !== `/${code}`) {
707
+ send(404, JSON.stringify({ ok: false, error: 'not found' }) + '\n', 'application/json; charset=utf-8')
708
+ return
709
+ }
710
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
711
+ send(405, JSON.stringify({ ok: false, error: 'method not allowed' }) + '\n', 'application/json; charset=utf-8')
712
+ return
713
+ }
714
+ send(200, this.consoleHtml({ code, offline: !host.socket }), 'text/html; charset=utf-8')
715
+ }
716
+
717
+ acceptVisitor(req, rawSocket, head, host) {
718
+ if (this.closed || !host) { rawSocket.destroy(); return }
719
+ // Same-origin browsers only; scripts without Origin pass (curl, CLI).
720
+ const origin = req.headers.origin
721
+ if (typeof origin === 'string' && origin !== '') {
722
+ let sameOrigin = false
723
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
724
+ if (!sameOrigin) { rawSocket.destroy(); return }
725
+ }
726
+ if (host.visitors.size >= this.maxVisitorsPerHost) {
727
+ this.log('relay', `visitor rejected on ${host.code}: visitor cap`)
728
+ rawSocket.destroy()
729
+ return
730
+ }
731
+ this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
732
+ const socket = adaptSocket(raw)
733
+ const visitorId = 'v-' + randomUUID().slice(0, 8)
734
+ host.visitors.set(visitorId, { socket, connectedAt: Date.now() })
735
+ this.log('relay', `visitor ${visitorId} connected to ${host.code}`)
736
+ // Engine composes the welcome payload; relay forwards it as-is.
737
+ this.toHost(host, 'visitor.hello', visitorId, undefined, (frame) => {
738
+ if (frame.ok) {
739
+ socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, host: frame.data.host, sessions: frame.data.sessions })
740
+ } else {
741
+ socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, error: frame.error })
742
+ }
743
+ })
744
+ let visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
745
+ const refreshIdle = () => {
746
+ this.disposeTimer(visitorIdleTimer)
747
+ visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
748
+ }
749
+ const offMessage = socket.onMessage((data) => {
750
+ if (typeof data !== 'string') return
751
+ refreshIdle()
752
+ const frame = parseFrame(data, 64 * 1024)
753
+ if (!frame || frame.t !== 'cmd' || typeof frame.id !== 'string') return
754
+ if (VISITOR_COMMANDS.has(frame.cmd)) {
755
+ this.toHost(host, frame.cmd, visitorId, frame.p, (result) => {
756
+ socket.send({ t: 'res', id: frame.id, ok: result.ok, ...(result.ok ? { data: result.data } : { error: result.error }) })
757
+ })
758
+ } else {
759
+ socket.send({ t: 'res', id: frame.id, ok: false, error: 'unsupported command' })
760
+ }
761
+ })
762
+ const offClose = socket.onClose(() => {
763
+ this.disposeTimer(visitorIdleTimer)
764
+ offMessage()
765
+ offClose()
766
+ host.visitors.delete(visitorId)
767
+ if (host.socket) this.toHost(host, 'visitor.gone', visitorId, undefined, () => {})
768
+ this.log('relay', `visitor ${visitorId} disconnected from ${host.code}`)
769
+ })
770
+ })
771
+ }
772
+
773
+ toHost(host, cmd, visitorId, p, onResult) {
774
+ const socket = host.socket
775
+ if (!socket) return onResult({ ok: false, error: '宿主机离线' })
776
+ const id = `c${++this.cmdSeq}`
777
+ host.pending.set(id, { onResult, timer: this.later(() => {
778
+ host.pending.delete(id)
779
+ onResult({ ok: false, error: '宿主机响应超时' })
780
+ }, 20_000) })
781
+ socket.send({ t: 'cmd', id, from: visitorId, cmd, ...(p === undefined ? {} : { p }) })
782
+ }
783
+
784
+ routeResult(host, frame) {
785
+ const entry = host.pending.get(frame.id)
786
+ if (!entry) return
787
+ host.pending.delete(frame.id)
788
+ this.disposeTimer(entry.timer)
789
+ entry.onResult({ ok: frame.ok === true, data: frame.data, error: frame.error })
790
+ }
791
+
792
+ routePush(host, frame) {
793
+ if (typeof frame.to !== 'string') return
794
+ const visitor = host.visitors.get(frame.to)
795
+ if (visitor) visitor.socket.send({ t: 'push', f: frame.f })
796
+ }
797
+
798
+ detachAll() {
799
+ this.closed = true
800
+ for (const timer of this.timers) clearTimeout(timer)
801
+ this.timers.clear()
802
+ for (const hostId of [...this.hosts.keys()]) this.removeHost(hostId, 'relay shutting down')
803
+ this.wss.close()
804
+ this.hostWss.close()
805
+ }
806
+
807
+ status() {
808
+ return {
809
+ mode: this.mode,
810
+ hosts: [...this.hosts.values()].map((host) => ({
811
+ hostId: host.hostId,
812
+ code: host.code,
813
+ name: host.name,
814
+ dsh: host.dsh,
815
+ online: host.socket !== undefined,
816
+ visitors: this.mode === 'gui' ? host.splices.size : host.visitors.size,
817
+ createdAt: host.createdAt,
818
+ })),
819
+ }
820
+ }
821
+ }
822
+
823
+ export { PROTOCOL_VERSION }