@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.
- package/README.md +77 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/bridge.js +377 -0
- package/lib/client.js +493 -0
- package/lib/console.js +434 -0
- package/lib/engine.js +367 -0
- package/lib/index.js +475 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +823 -0
- package/lib/remote.js +181 -0
- package/lib/ws-adapter.js +49 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
|
@@ -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 }
|