@skillpie/dsh-remote 0.0.0-stage → 1.1.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 +95 -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/file-route.js +159 -0
- package/lib/index.js +496 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +1025 -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,1025 @@
|
|
|
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
|
+
import { fileRouteKey } from './file-route.js'
|
|
36
|
+
|
|
37
|
+
const HELLO_TIMEOUT_MS = 5_000
|
|
38
|
+
const OFFLINE_GRACE_MS = 10 * 60_000
|
|
39
|
+
const VISITOR_IDLE_MS = 90_000
|
|
40
|
+
const HTTP_OP_TIMEOUT_MS = 150_000
|
|
41
|
+
const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024
|
|
42
|
+
/**
|
|
43
|
+
* Inbound caps. Host frames carry whole GUI responses as binary chunks
|
|
44
|
+
* (capped at 8 MiB each by the sender); visitor frames carry POST bodies in
|
|
45
|
+
* the same chunking, so the visitor cap stays generous too. Commands in
|
|
46
|
+
* console mode are tiny and checked separately.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_HOST_FRAME_BYTES = 8 * 1024 * 1024
|
|
49
|
+
const MAX_HTML_INJECT_BYTES = 4 * 1024 * 1024
|
|
50
|
+
const MAX_VISITOR_FRAME_BYTES = 8 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
export class RelayCore {
|
|
53
|
+
constructor({ webServer, consoleHtml, log = () => {}, maxVisitorsPerHost = 8, mode = 'console' }) {
|
|
54
|
+
this.webServer = webServer
|
|
55
|
+
this.consoleHtml = consoleHtml
|
|
56
|
+
this.log = log
|
|
57
|
+
this.maxVisitorsPerHost = maxVisitorsPerHost
|
|
58
|
+
this.mode = mode === 'gui' ? 'gui' : 'console'
|
|
59
|
+
/** hostId → host record */
|
|
60
|
+
this.hosts = new Map()
|
|
61
|
+
/** code → hostId */
|
|
62
|
+
this.codeIndex = new Map()
|
|
63
|
+
this.wss = new WebSocketServer({ noServer: true, maxPayload: MAX_VISITOR_FRAME_BYTES })
|
|
64
|
+
this.hostWss = new WebSocketServer({ noServer: true, maxPayload: MAX_HOST_FRAME_BYTES })
|
|
65
|
+
this.cmdSeq = 0
|
|
66
|
+
this.timers = new Set()
|
|
67
|
+
this.closed = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
later(fn, ms) {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
this.timers.delete(timer)
|
|
73
|
+
if (!this.closed) fn()
|
|
74
|
+
}, ms)
|
|
75
|
+
this.timers.add(timer)
|
|
76
|
+
return timer
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
disposeTimer(timer) {
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
this.timers.delete(timer)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** webServer upgrade handler for the host control channel. */
|
|
85
|
+
acceptHostUpgrade(req, rawSocket, head) {
|
|
86
|
+
if (this.closed) { rawSocket.destroy(); return }
|
|
87
|
+
try {
|
|
88
|
+
this.hostWss.handleUpgrade(req, rawSocket, head, (raw) => {
|
|
89
|
+
const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
|
|
90
|
+
? req.headers['x-forwarded-for'].split(',')[0].trim()
|
|
91
|
+
: ''
|
|
92
|
+
this.acceptHostSocket(adaptSocket(raw), forwarded)
|
|
93
|
+
})
|
|
94
|
+
} catch (error) {
|
|
95
|
+
this.log('relay', `宿主升级失败(已隔离): ${error?.message ?? error}`)
|
|
96
|
+
try { rawSocket.destroy() } catch { /* gone */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One host control frame, isolated from socket-listener exceptions. */
|
|
101
|
+
onHostFrame(state, socket, data, fail, ip) {
|
|
102
|
+
if (typeof data !== 'string') {
|
|
103
|
+
// Binary: GUI splice payload (response chunk / ws bytes).
|
|
104
|
+
if (!state.hostId) return
|
|
105
|
+
const host = this.hosts.get(state.hostId)
|
|
106
|
+
if (!host) return
|
|
107
|
+
host.lastSeen = Date.now()
|
|
108
|
+
const decoded = decodeBin(data)
|
|
109
|
+
if (!decoded) return
|
|
110
|
+
this.routeBin(host, decoded.meta, decoded.payload)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const frame = parseFrame(data, MAX_HOST_FRAME_BYTES)
|
|
114
|
+
if (!frame) {
|
|
115
|
+
if (data.length > MAX_HOST_FRAME_BYTES) {
|
|
116
|
+
this.log('relay', `dropped oversized host frame (${Math.round(data.length / 1024)} KiB)`)
|
|
117
|
+
}
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (frame.t === 'hello') {
|
|
121
|
+
this.disposeTimer(state.helloTimer)
|
|
122
|
+
const registeredId = this.registerHost(socket, frame, state.ip ?? '', fail)
|
|
123
|
+
if (registeredId) state.hostId = registeredId
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (!state.hostId) return
|
|
127
|
+
const host = this.hosts.get(state.hostId)
|
|
128
|
+
if (!host) return
|
|
129
|
+
host.lastSeen = Date.now()
|
|
130
|
+
if (frame.t === 'hb') {
|
|
131
|
+
socket.send({ t: 'hb' })
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (frame.t === 'res') this.routeResult(host, frame)
|
|
135
|
+
else if (frame.t === 'push') this.routePush(host, frame)
|
|
136
|
+
else if (frame.t === 'hres' || frame.t === 'hfail') this.routeHttp(host, frame)
|
|
137
|
+
else if (frame.t === 'hend') this.finishHttp(host, frame.id)
|
|
138
|
+
else if (frame.t === 'wok' || frame.t === 'wfail') this.routeSpliceControl(host, frame)
|
|
139
|
+
else if (frame.t === 'wclose') this.finishSplice(host, frame)
|
|
140
|
+
else if (frame.t === 'wdata-text') this.routeText(host, frame)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Accept one host end (already adapted: pipe end or ws adapter). */
|
|
144
|
+
acceptHostSocket(socket, ip = '') {
|
|
145
|
+
const state = { hostId: undefined, helloTimer: undefined, ip }
|
|
146
|
+
const fail = (error) => {
|
|
147
|
+
socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: false, error })
|
|
148
|
+
this.later(() => socket.close(), 150)
|
|
149
|
+
}
|
|
150
|
+
state.helloTimer = this.later(() => {
|
|
151
|
+
if (!state.hostId) fail('hello timeout')
|
|
152
|
+
}, HELLO_TIMEOUT_MS)
|
|
153
|
+
const offMessage = socket.onMessage((data) => {
|
|
154
|
+
try {
|
|
155
|
+
this.onHostFrame(state, socket, data, fail, state.ip)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.log('relay', `宿主机帧处理异常(已隔离): ${error?.message ?? error}`)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
const offClose = socket.onClose(() => {
|
|
161
|
+
this.disposeTimer(state.helloTimer)
|
|
162
|
+
offMessage()
|
|
163
|
+
offClose()
|
|
164
|
+
if (!state.hostId) return
|
|
165
|
+
const host = this.hosts.get(state.hostId)
|
|
166
|
+
if (!host || host.socket !== socket) return
|
|
167
|
+
this.log('relay', `host offline: ${host.name} (${host.code})`)
|
|
168
|
+
host.socket = undefined
|
|
169
|
+
for (const visitor of host.visitors.values()) visitor.socket.close()
|
|
170
|
+
host.visitors.clear()
|
|
171
|
+
host.pending.clear()
|
|
172
|
+
for (const op of host.httpOps.values()) {
|
|
173
|
+
this.disposeTimer(op.timer)
|
|
174
|
+
try { op.res.destroy() } catch { op.res.end() }
|
|
175
|
+
}
|
|
176
|
+
host.httpOps.clear()
|
|
177
|
+
for (const splice of host.splices.values()) {
|
|
178
|
+
try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
|
|
179
|
+
}
|
|
180
|
+
host.splices.clear()
|
|
181
|
+
// Keep the routes briefly so a reconnecting host reclaims them; a host
|
|
182
|
+
// that stays away loses the registration after the grace window.
|
|
183
|
+
host.offlineTimer = this.later(() => this.removeHost(host.hostId, 'offline grace elapsed'), OFFLINE_GRACE_MS)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
registerHost(socket, hello, ip, fail) {
|
|
188
|
+
const hostId = typeof hello.hostId === 'string' ? hello.hostId.slice(0, 128) : ''
|
|
189
|
+
const code = isValidCode(hello.code) ? hello.code : null
|
|
190
|
+
if (!hostId || !code) return fail('invalid hello')
|
|
191
|
+
const existing = this.hosts.get(hostId)
|
|
192
|
+
const token = typeof hello.token === 'string' ? hello.token : ''
|
|
193
|
+
if (existing) {
|
|
194
|
+
if (token !== '' && hashToken(token) !== existing.tokenHash) {
|
|
195
|
+
this.log('relay', `rejected host ${hostId}: token mismatch`)
|
|
196
|
+
return fail('hostId already registered with a different token')
|
|
197
|
+
}
|
|
198
|
+
if (token === '' && existing.tokenHash !== '') {
|
|
199
|
+
this.log('relay', `rejected host ${hostId}: empty token for known identity`)
|
|
200
|
+
return fail('hostId already registered; token required')
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Code anti-hijack: a live code bound to another host id never rebinds.
|
|
204
|
+
const codeOwner = this.codeIndex.get(code)
|
|
205
|
+
if (codeOwner !== undefined && codeOwner !== hostId && this.hosts.get(codeOwner)) {
|
|
206
|
+
return fail('code already in use')
|
|
207
|
+
}
|
|
208
|
+
const minted = token === '' || existing?.tokenHash !== hashToken(token)
|
|
209
|
+
const newToken = minted ? randomBytes(24).toString('hex') : token
|
|
210
|
+
if (existing?.offlineTimer) this.disposeTimer(existing.offlineTimer)
|
|
211
|
+
if (existing?.socket && existing.socket !== socket) existing.socket.close()
|
|
212
|
+
if (codeOwner !== undefined && codeOwner !== hostId) {
|
|
213
|
+
// Old owner is gone (removeHost clears the index only for its own code;
|
|
214
|
+
// reaching here means a stale index entry) — reclaim defensively.
|
|
215
|
+
this.codeIndex.delete(code)
|
|
216
|
+
}
|
|
217
|
+
const host = existing ?? {
|
|
218
|
+
hostId,
|
|
219
|
+
visitors: new Map(),
|
|
220
|
+
pending: new Map(),
|
|
221
|
+
httpOps: new Map(),
|
|
222
|
+
splices: new Map(),
|
|
223
|
+
routes: [],
|
|
224
|
+
fileKey: undefined,
|
|
225
|
+
createdAt: Date.now(),
|
|
226
|
+
}
|
|
227
|
+
if (existing) {
|
|
228
|
+
for (const route of host.routes) route()
|
|
229
|
+
host.routes = []
|
|
230
|
+
}
|
|
231
|
+
const previousCode = host.code
|
|
232
|
+
host.code = code
|
|
233
|
+
host.name = typeof hello.name === 'string' ? hello.name.slice(0, 80) : host.name
|
|
234
|
+
host.dsh = typeof hello.dsh === 'string' ? hello.dsh.slice(0, 40) : host.dsh
|
|
235
|
+
host.ip = ip
|
|
236
|
+
host.fileKey = fileRouteKey(hostId)
|
|
237
|
+
host.tokenHash = hashToken(newToken)
|
|
238
|
+
host.socket = socket
|
|
239
|
+
host.lastSeen = Date.now()
|
|
240
|
+
this.hosts.set(hostId, host)
|
|
241
|
+
this.codeIndex.set(code, hostId)
|
|
242
|
+
if (previousCode && previousCode !== code && this.codeIndex.get(previousCode) === hostId) {
|
|
243
|
+
this.codeIndex.delete(previousCode)
|
|
244
|
+
}
|
|
245
|
+
// Mount the public surface for this code.
|
|
246
|
+
try {
|
|
247
|
+
if (this.mode === 'gui') {
|
|
248
|
+
host.routes.push(this.webServer.register({
|
|
249
|
+
kind: 'prefix',
|
|
250
|
+
path: `/${code}`,
|
|
251
|
+
handler: (req, res) => {
|
|
252
|
+
try { this.proxyHttp(req, res, code, host) } catch (error) {
|
|
253
|
+
this.log('relay', `代理请求失败(已隔离): ${error?.message ?? error}`)
|
|
254
|
+
try { res.writeHead(500).end() } catch { /* gone */ }
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}))
|
|
258
|
+
host.routes.push(this.webServer.registerUpgrade({
|
|
259
|
+
path: `/${code}/api/remote.mux`,
|
|
260
|
+
handler: (req, upgradeSocket, head) => {
|
|
261
|
+
try { this.acceptWsSplice(req, upgradeSocket, head, host) } catch (error) {
|
|
262
|
+
this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
|
|
263
|
+
try { upgradeSocket.destroy() } catch { /* gone */ }
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
}))
|
|
267
|
+
} else {
|
|
268
|
+
host.routes.push(this.webServer.register({
|
|
269
|
+
kind: 'prefix',
|
|
270
|
+
path: `/${code}`,
|
|
271
|
+
handler: (req, res) => {
|
|
272
|
+
try { this.serveConsole(req, res, code, host) } catch (error) {
|
|
273
|
+
this.log('relay', `控制台请求失败(已隔离): ${error?.message ?? error}`)
|
|
274
|
+
try { res.writeHead(500).end() } catch { /* gone */ }
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
}))
|
|
278
|
+
host.routes.push(this.webServer.registerUpgrade({
|
|
279
|
+
path: `/${code}/ws`,
|
|
280
|
+
handler: (req, upgradeSocket, head) => {
|
|
281
|
+
try { this.acceptVisitor(req, upgradeSocket, head, host) } catch (error) {
|
|
282
|
+
this.log('relay', `访客升级失败(已隔离): ${error?.message ?? error}`)
|
|
283
|
+
try { upgradeSocket.destroy() } catch { /* gone */ }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
}))
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
this.log('relay', `route registration failed for ${code}: ${error?.message ?? error}`)
|
|
290
|
+
this.codeIndex.delete(code)
|
|
291
|
+
this.hosts.delete(hostId)
|
|
292
|
+
return fail('route registration failed')
|
|
293
|
+
}
|
|
294
|
+
this.log('relay', `host registered: ${host.name} code=${code} mode=${this.mode}${minted ? ' (token minted)' : ''}`)
|
|
295
|
+
socket.send({ t: 'hello', v: PROTOCOL_VERSION, ok: true, token: newToken })
|
|
296
|
+
return hostId
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
removeHost(hostId, reason) {
|
|
300
|
+
const host = this.hosts.get(hostId)
|
|
301
|
+
if (!host) return
|
|
302
|
+
this.hosts.delete(hostId)
|
|
303
|
+
if (this.codeIndex.get(host.code) === hostId) this.codeIndex.delete(host.code)
|
|
304
|
+
if (host.offlineTimer) this.disposeTimer(host.offlineTimer)
|
|
305
|
+
for (const route of host.routes) route()
|
|
306
|
+
host.routes = []
|
|
307
|
+
host.socket?.close()
|
|
308
|
+
for (const visitor of host.visitors.values()) visitor.socket.close()
|
|
309
|
+
host.visitors.clear()
|
|
310
|
+
host.pending.clear()
|
|
311
|
+
for (const op of host.httpOps.values()) {
|
|
312
|
+
this.disposeTimer(op.timer)
|
|
313
|
+
try { op.res.destroy() } catch { op.res.end() }
|
|
314
|
+
}
|
|
315
|
+
host.httpOps.clear()
|
|
316
|
+
for (const splice of host.splices.values()) {
|
|
317
|
+
try { splice.socket.close() } catch { try { splice.raw.terminate() } catch { /* gone */ } }
|
|
318
|
+
}
|
|
319
|
+
host.splices.clear()
|
|
320
|
+
this.log('relay', `host removed: ${host.name} (${host.code}) — ${reason}`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ── gui mode: transparent HTTP + WS splice ─────────────────────────── */
|
|
324
|
+
|
|
325
|
+
stripPrefix(pathname, code) {
|
|
326
|
+
if (pathname === `/${code}`) return '/'
|
|
327
|
+
return pathname.slice(`/${code}`.length) || '/'
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Strip the per-code prefix from visitor cookie names. */
|
|
331
|
+
stripCodeCookies(header, code) {
|
|
332
|
+
if (typeof header !== 'string' || header === '') return undefined
|
|
333
|
+
const prefix = `r${code}-`
|
|
334
|
+
const kept = []
|
|
335
|
+
for (const segment of header.split(';')) {
|
|
336
|
+
const pair = segment.trim()
|
|
337
|
+
if (!pair) continue
|
|
338
|
+
const at = pair.indexOf('=')
|
|
339
|
+
if (at === -1) continue
|
|
340
|
+
const name = pair.slice(0, at)
|
|
341
|
+
if (!name.startsWith(prefix)) continue
|
|
342
|
+
kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
|
|
343
|
+
}
|
|
344
|
+
return kept.length > 0 ? kept.join('; ') : undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Prefix visitor cookie names and scope Path to the code mount. */
|
|
348
|
+
rewriteSetCookie(line, code) {
|
|
349
|
+
const prefix = `r${code}-`
|
|
350
|
+
const at = line.indexOf('=')
|
|
351
|
+
if (at === -1) return line
|
|
352
|
+
let out = `${prefix}${line.slice(0, at)}=${line.slice(at + 1)}`
|
|
353
|
+
out = out.replace(/Path=\/(?![^\s;])/i, `Path=/${code}/`)
|
|
354
|
+
return out
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
offlineHtml(code) {
|
|
358
|
+
return `<!DOCTYPE html>
|
|
359
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="5"><script>(function(){try{localStorage.setItem('dsh-remote-last-code','${code}');sessionStorage.setItem('dsh-remote-last-code','${code}');if((location.search||'').indexOf('gui')===-1)history.replaceState(null,'','/')}catch(e){}})();</script>
|
|
360
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
361
|
+
<title>远程控制 ${code}</title>
|
|
362
|
+
<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>
|
|
363
|
+
<body><div class="big">宿主机暂时离线</div><div class="hint">远程码 <code>${code}</code> 有效,但宿主机尚未连上中转站。页面会自动重试。</div></body></html>`
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Visitor-side URL shim. The proxied GUI and plugin panels call absolute
|
|
368
|
+
* same-origin paths (`/api/...`, `/dsh-dba/...`), which only resolve on the
|
|
369
|
+
* host's own origin. Under `/<code>/` they must ride the prefix so they land
|
|
370
|
+
* on the relay's proxy route and splice mux instead of the SPA fallback.
|
|
371
|
+
* Injected into proxied HTML right after `<head>`; runs before app scripts.
|
|
372
|
+
*/
|
|
373
|
+
visitorShimScript(code) {
|
|
374
|
+
const prefix = JSON.stringify(`/${code}`)
|
|
375
|
+
const map = JSON.stringify(['/api/', '/dsh-dba/', '/dsh-market/'])
|
|
376
|
+
return `<script>(function(){
|
|
377
|
+
window.__DSH_REMOTE_VISITOR__=true;
|
|
378
|
+
try{localStorage.setItem('dsh-remote-last-code',${JSON.stringify(code)})}catch(e){}
|
|
379
|
+
var P=${prefix};var M=${map};
|
|
380
|
+
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}
|
|
381
|
+
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}
|
|
382
|
+
var of=window.fetch;
|
|
383
|
+
window.fetch=function(input,init){
|
|
384
|
+
try{
|
|
385
|
+
if(typeof input==='string')return of(rebase(input),init);
|
|
386
|
+
if(input&&typeof input.url==='string'){var nu=rebase(input.url);if(nu!==input.url)return of(new Request(nu,input),init)}
|
|
387
|
+
}catch(e){}
|
|
388
|
+
return of(input,init);
|
|
389
|
+
};
|
|
390
|
+
var OW=window.WebSocket;
|
|
391
|
+
window.WebSocket=class extends OW{constructor(u,p){u=rebase(typeof u==='string'?u:String(u));super(u,p)}};
|
|
392
|
+
})(/* dsh-remote visitor shim */);</script>`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Code concealment: remember the code, pin relative resolution to the
|
|
397
|
+
* tunnel prefix (history.replaceState would change how `<base href="./">`
|
|
398
|
+
* resolves), strip the code from the visible URL, and register the
|
|
399
|
+
* origin-level service worker so later visits to the bare origin restore
|
|
400
|
+
* the tunnel without the code ever reappearing in the address bar.
|
|
401
|
+
* Injected right after `<base>`; runs before app scripts.
|
|
402
|
+
*/
|
|
403
|
+
concealScript(code) {
|
|
404
|
+
const codeJson = JSON.stringify(code)
|
|
405
|
+
const prefixJson = JSON.stringify(`/${code}/`)
|
|
406
|
+
return `<script>(function(){
|
|
407
|
+
window.__DSH_REMOTE_CONCEAL__=true;
|
|
408
|
+
try{
|
|
409
|
+
var C=${codeJson};
|
|
410
|
+
if((location.search||'').indexOf('gui')!==-1){try{sessionStorage.setItem('dsh-remote-bypass','1')}catch(e){}return}
|
|
411
|
+
var b=document.querySelector('base');
|
|
412
|
+
if(b)b.setAttribute('href',${prefixJson});
|
|
413
|
+
try{localStorage.setItem('dsh-remote-last-code',C)}catch(e){}
|
|
414
|
+
try{sessionStorage.setItem('dsh-remote-last-code',C)}catch(e){}
|
|
415
|
+
try{history.replaceState(null,'','/')}catch(e){}
|
|
416
|
+
if(navigator.serviceWorker&&window.isSecureContext){
|
|
417
|
+
navigator.serviceWorker.register('/dsh-remote-sw.js?code='+C,{scope:'/'}).catch(function(){})
|
|
418
|
+
}
|
|
419
|
+
}catch(e){}
|
|
420
|
+
})(/* dsh-remote conceal */);</script>`
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Visitor-side "download file" menu patch. The host GUI's file menu
|
|
425
|
+
* (OpenTargetButton) only hands paths to the host desktop; a remote
|
|
426
|
+
* visitor has no way to save the file locally. This script watches for
|
|
427
|
+
* that menu opening, prepends a "下载文件" item (styled after the native
|
|
428
|
+
* menu rows) and serves the file through the host's `/dsh-remote-file`
|
|
429
|
+
* route via the tunnel.
|
|
430
|
+
*
|
|
431
|
+
* The absolute path comes from the preview header's PathLabel
|
|
432
|
+
* (`[data-path-label]` holds it in `title`); as a fallback we tap mux
|
|
433
|
+
* frames for the last path the app requested (openWorkspacePath /
|
|
434
|
+
* workspacePathApplications payloads carry `"path":"…"`).
|
|
435
|
+
* Injected last, after the visitor shim (its WebSocket wrapper stays).
|
|
436
|
+
*/
|
|
437
|
+
downloadMenuScript(code, key) {
|
|
438
|
+
const codeJson = JSON.stringify(code)
|
|
439
|
+
const keyJson = JSON.stringify(String(key ?? ''))
|
|
440
|
+
return `<script>(function(){
|
|
441
|
+
window.__DSH_REMOTE_DOWNLOAD__=true;
|
|
442
|
+
try{
|
|
443
|
+
var C=${codeJson};var K=${keyJson};
|
|
444
|
+
var M=/显示文件位置|Show file location|ファイルの場所| 파일 위치|Открыть расположение/;
|
|
445
|
+
var PATH_RE=/^([\\/~]|[A-Za-z]:[\\\\\\/])/;
|
|
446
|
+
function isPath(s){return typeof s==='string'&&s.length>1&&s.length<1024&&PATH_RE.test(s)&&s.indexOf('/')>-1}
|
|
447
|
+
var lastMux=null;
|
|
448
|
+
function notePath(p){if(isPath(p))lastMux={path:p,at:Date.now()}}
|
|
449
|
+
var OW=window.WebSocket;
|
|
450
|
+
window.WebSocket=class extends OW{constructor(u,p){super(u,p);var self=this;var s=super.send;
|
|
451
|
+
// tap outgoing mux frames for file paths (fallback when no PathLabel DOM)
|
|
452
|
+
this.send=function(data){
|
|
453
|
+
try{
|
|
454
|
+
if(typeof data==='string'&&data.indexOf('"path"')>-1){
|
|
455
|
+
var m=data.match(/"path"\\s*:\\s*"((?:[^"\\\\]|\\\\(?:u[0-9a-fA-F]{4}|.))*)"/);
|
|
456
|
+
if(m){try{notePath(JSON.parse('"'+m[1]+'"'))}catch(e){}}
|
|
457
|
+
}
|
|
458
|
+
}catch(e){}
|
|
459
|
+
return s.call(self,data)}
|
|
460
|
+
}};
|
|
461
|
+
function base(){return location.pathname.replace(new RegExp('^/'+C+'(/|$)'),'/')==='/'
|
|
462
|
+
? '/'+C : location.pathname.split('/').slice(0,2).join('/')}
|
|
463
|
+
function download(path){
|
|
464
|
+
var url=base()+'/dsh-remote-file?path='+encodeURIComponent(path);
|
|
465
|
+
return fetch(url,{credentials:'omit',headers:{'x-dsh-remote-key':K}}).then(function(r){
|
|
466
|
+
if(!r.ok)throw new Error('HTTP '+r.status);
|
|
467
|
+
var cd=r.headers.get('content-disposition')||'';
|
|
468
|
+
var m=cd.match(/filename\\*=UTF-8''([^;]+)/i);
|
|
469
|
+
var name=m?decodeURIComponent(m[1]):(path.split('/').pop()||'download');
|
|
470
|
+
return r.blob().then(function(b){
|
|
471
|
+
var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=name;
|
|
472
|
+
document.body.appendChild(a);a.click();a.remove();
|
|
473
|
+
setTimeout(function(){URL.revokeObjectURL(a.href)},30000);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function findLabel(el){
|
|
478
|
+
for(var n=el,i=0;n&&i<8;i++,n=n.parentElement){
|
|
479
|
+
var lbl=n.querySelector&&n.querySelector('[data-path-label]');
|
|
480
|
+
if(lbl){var t=lbl.getAttribute('title')||lbl.textContent;if(isPath(t))return t}
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
function patch(menu){
|
|
485
|
+
if(menu.getAttribute('data-dsh-remote-dl'))return;
|
|
486
|
+
menu.setAttribute('data-dsh-remote-dl','1');
|
|
487
|
+
var items=menu.querySelectorAll('[role=menuitem]');
|
|
488
|
+
if(!items.length)return;
|
|
489
|
+
var itemsArr=Array.prototype.slice.call(items);
|
|
490
|
+
// Only file menus: the native reveal item, or a menu anchored by the file target.
|
|
491
|
+
var trigger=null;
|
|
492
|
+
var mid=menu.id||'';
|
|
493
|
+
if(mid){var cands=document.querySelectorAll('[aria-controls]');
|
|
494
|
+
for(var i=0;i<cands.length;i++){var v=(cands[i].getAttribute('aria-controls')||'').split(/\\s+/);
|
|
495
|
+
if(v.indexOf(mid)>-1){trigger=cands[i];break}}}
|
|
496
|
+
var hasReveal=itemsArr.some(function(it){return M.test(it.textContent||'')});
|
|
497
|
+
var isFile=hasReveal||(trigger&&trigger.closest&&trigger.closest('[data-open-target="file"]'));
|
|
498
|
+
if(!isFile)return;
|
|
499
|
+
var first=itemsArr[0];
|
|
500
|
+
var btn=document.createElement('button');
|
|
501
|
+
btn.setAttribute('role','menuitem');
|
|
502
|
+
btn.setAttribute('type','button');
|
|
503
|
+
btn.className=first.className||'';
|
|
504
|
+
btn.style.cssText='all:unset;display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;cursor:pointer;color:inherit;font:inherit;';
|
|
505
|
+
for(var a=0;a<first.attributes.length;a++){var at=first.attributes[a];
|
|
506
|
+
if(at.name!=='role'&&at.name!=='type'&&at.name!=='disabled'&&at.name.indexOf('data-')!==0&&!/^(id|aria-(labelledby|describedby))$/.test(at.name)){try{btn.setAttribute(at.name,at.value)}catch(e){}}}
|
|
507
|
+
btn.setAttribute('data-dsh-remote-dl-item','1');
|
|
508
|
+
var zh=(document.documentElement.lang||'').toLowerCase().indexOf('zh')===0;
|
|
509
|
+
btn.innerHTML='<span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;opacity:.85">'
|
|
510
|
+
+'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.2v7.6m0 0 2.8-2.8M8 9.8 5.2 7M3 12.6h10" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
|
511
|
+
+'</span><span></span>';
|
|
512
|
+
btn.lastElementChild.textContent=zh?'下载文件':'Download file';
|
|
513
|
+
btn.addEventListener('click',function(ev){
|
|
514
|
+
ev.preventDefault();ev.stopPropagation();
|
|
515
|
+
var path=findLabel(trigger||first)||((lastMux&&Date.now()-(lastMux.at||0)<120000)?lastMux.path:null);
|
|
516
|
+
if(!path){
|
|
517
|
+
var t=btn.lastElementChild;var old=t.textContent;
|
|
518
|
+
t.textContent=zh?'无法定位文件':'Cannot locate file';
|
|
519
|
+
setTimeout(function(){t.textContent=old},1200);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
download(path).catch(function(){});
|
|
523
|
+
});
|
|
524
|
+
first.parentNode.insertBefore(btn,first);
|
|
525
|
+
}
|
|
526
|
+
var mo=new MutationObserver(function(muts){
|
|
527
|
+
for(var i=0;i<muts.length;i++){var add=muts[i].addedNodes;
|
|
528
|
+
for(var j=0;j<add.length;j++){var n=add[j];
|
|
529
|
+
if(!n||n.nodeType!==1)continue;
|
|
530
|
+
var menus=n.matches&&n.matches('[role=menu]')?[n]:(n.querySelectorAll?n.querySelectorAll('[role=menu]'):[]);
|
|
531
|
+
for(var k=0;k<menus.length;k++){try{patch(menus[k])}catch(e){}}
|
|
532
|
+
}}
|
|
533
|
+
});
|
|
534
|
+
function start(){try{mo.observe(document.body,{childList:true,subtree:true})}catch(e){}}
|
|
535
|
+
if(document.body)start();else document.addEventListener('DOMContentLoaded',start);
|
|
536
|
+
// Menus already open before the observer started.
|
|
537
|
+
try{document.querySelectorAll('[role=menu]').forEach(patch)}catch(e){}
|
|
538
|
+
}catch(e){}
|
|
539
|
+
})(/* dsh-remote download menu */);</script>`
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
injectVisitorShim(html, code, fileKey) {
|
|
543
|
+
if (typeof html !== 'string' || html.length === 0) return html
|
|
544
|
+
let scripts = this.concealScript(code) + this.visitorShimScript(code)
|
|
545
|
+
if (typeof fileKey === 'string' && fileKey.length > 0) {
|
|
546
|
+
scripts += this.downloadMenuScript(code, fileKey)
|
|
547
|
+
}
|
|
548
|
+
if (/<base[^>]*>/i.test(html)) return html.replace(/<base[^>]*>/i, (m) => m + scripts)
|
|
549
|
+
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + scripts)
|
|
550
|
+
return scripts + html
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
proxyHttp(req, res, code, host) {
|
|
554
|
+
let pathname = '/'
|
|
555
|
+
let search = ''
|
|
556
|
+
try {
|
|
557
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
558
|
+
pathname = url.pathname
|
|
559
|
+
search = url.search
|
|
560
|
+
} catch { pathname = '/' }
|
|
561
|
+
const subpath = this.stripPrefix(pathname, code)
|
|
562
|
+
const targetPath = subpath + search
|
|
563
|
+
const send = (status, body, headers = {}) => {
|
|
564
|
+
if (res.headersSent) { try { res.destroy() } catch { res.end() } ; return }
|
|
565
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers })
|
|
566
|
+
res.end(body)
|
|
567
|
+
}
|
|
568
|
+
if (pathname === `/${code}` && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
569
|
+
res.writeHead(302, { location: `/${code}/`, 'cache-control': 'no-store' })
|
|
570
|
+
res.end()
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
if (!host.socket) {
|
|
574
|
+
if (subpath === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
575
|
+
send(200, this.offlineHtml(code), { 'Content-Type': 'text/html; charset=utf-8' })
|
|
576
|
+
} else {
|
|
577
|
+
send(504, '宿主机离线\n', { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
578
|
+
}
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
if (host.httpOps.size >= 64) {
|
|
582
|
+
send(429, 'too many in-flight requests\n', { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
const id = `h${++this.cmdSeq}`
|
|
586
|
+
// Buffer the request body (GUI uploads included), then frame it over.
|
|
587
|
+
const chunks = []
|
|
588
|
+
let size = 0
|
|
589
|
+
let done = false
|
|
590
|
+
let failed = false
|
|
591
|
+
req.on('data', (chunk) => {
|
|
592
|
+
size += chunk.length
|
|
593
|
+
if (size > MAX_REQUEST_BODY_BYTES) {
|
|
594
|
+
if (!failed) { failed = true; send(413, 'payload too large\n', { 'Content-Type': 'text/plain; charset=utf-8' }) }
|
|
595
|
+
req.destroy()
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
chunks.push(chunk)
|
|
599
|
+
})
|
|
600
|
+
req.on('error', () => { failed = true })
|
|
601
|
+
req.on('end', () => { done = true })
|
|
602
|
+
const op = { res, timer: undefined, done: false, method: req.method }
|
|
603
|
+
host.httpOps.set(id, op)
|
|
604
|
+
op.timer = this.later(() => {
|
|
605
|
+
if (!op.done) {
|
|
606
|
+
host.httpOps.delete(id)
|
|
607
|
+
send(504, '宿主机响应超时\n', { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
608
|
+
}
|
|
609
|
+
}, HTTP_OP_TIMEOUT_MS)
|
|
610
|
+
res.on('close', () => {
|
|
611
|
+
if (!op.done && host.socket) host.socket.send({ t: 'habort', id })
|
|
612
|
+
})
|
|
613
|
+
const dispatch = () => {
|
|
614
|
+
if (failed || this.closed) { host.httpOps.delete(id); return }
|
|
615
|
+
const body = Buffer.concat(chunks)
|
|
616
|
+
host.socket.send({
|
|
617
|
+
t: 'hreq',
|
|
618
|
+
id,
|
|
619
|
+
m: req.method,
|
|
620
|
+
p: targetPath,
|
|
621
|
+
h: {
|
|
622
|
+
...sanitize(req.headers, code),
|
|
623
|
+
...(body.length > 0 ? { 'content-length': String(body.length) } : {}),
|
|
624
|
+
},
|
|
625
|
+
})
|
|
626
|
+
if (body.length > 0) host.socket.sendRaw(encodeBin({ t: 'hchunk', id }, body))
|
|
627
|
+
host.socket.send({ t: 'hend', id })
|
|
628
|
+
}
|
|
629
|
+
if (done) dispatch()
|
|
630
|
+
else req.on('end', () => dispatch())
|
|
631
|
+
|
|
632
|
+
function sanitize(headers, cd) {
|
|
633
|
+
const out = {}
|
|
634
|
+
const drop = new Set(['host', 'origin', 'referer', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'accept-encoding', 'content-length'])
|
|
635
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
636
|
+
const lower = name.toLowerCase()
|
|
637
|
+
if (drop.has(lower) || lower.startsWith('sec-')) continue
|
|
638
|
+
if (lower === 'cookie') {
|
|
639
|
+
const stripped = stripCookiesHeader(value, cd)
|
|
640
|
+
if (stripped) out.cookie = stripped
|
|
641
|
+
continue
|
|
642
|
+
}
|
|
643
|
+
out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
|
|
644
|
+
}
|
|
645
|
+
return out
|
|
646
|
+
}
|
|
647
|
+
function stripCookiesHeader(header, cd) {
|
|
648
|
+
if (typeof header !== 'string' || header === '') return undefined
|
|
649
|
+
const prefix = `r${cd}-`
|
|
650
|
+
const kept = []
|
|
651
|
+
for (const segment of header.split(';')) {
|
|
652
|
+
const pair = segment.trim()
|
|
653
|
+
const at = pair.indexOf('=')
|
|
654
|
+
if (at === -1) continue
|
|
655
|
+
const name = pair.slice(0, at)
|
|
656
|
+
if (!name.startsWith(prefix)) continue
|
|
657
|
+
kept.push(`${name.slice(prefix.length)}=${pair.slice(at + 1)}`)
|
|
658
|
+
}
|
|
659
|
+
return kept.length > 0 ? kept.join('; ') : undefined
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
routeHttp(host, frame) {
|
|
664
|
+
const op = host.httpOps.get(frame.id)
|
|
665
|
+
if (!op) return
|
|
666
|
+
if (frame.t === 'hfail') {
|
|
667
|
+
host.httpOps.delete(frame.id)
|
|
668
|
+
this.disposeTimer(op.timer)
|
|
669
|
+
op.done = true
|
|
670
|
+
if (!op.res.headersSent) {
|
|
671
|
+
op.res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
672
|
+
op.res.end(`宿主机桥接失败: ${frame.error ?? 'unknown'}\n`)
|
|
673
|
+
} else {
|
|
674
|
+
try { op.res.destroy() } catch { op.res.end() }
|
|
675
|
+
}
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
const headers = { ...(frame.h ?? {}) }
|
|
679
|
+
const setCookies = Array.isArray(frame.sc) ? frame.sc.map((line) => this.rewriteSetCookie(line, host.code)) : []
|
|
680
|
+
if (setCookies.length > 0) headers['set-cookie'] = setCookies
|
|
681
|
+
// HTML 响应缓冲注入访客 shim(GET/HEAD 才有 body;上限见 MAX_HTML_INJECT_BYTES)。
|
|
682
|
+
if (
|
|
683
|
+
(op.method === 'GET' || op.method === 'HEAD') &&
|
|
684
|
+
/text\/html/i.test(String(headers['content-type'] ?? ''))
|
|
685
|
+
) {
|
|
686
|
+
op.htmlInject = true
|
|
687
|
+
op.parts = []
|
|
688
|
+
op.htmlBytes = 0
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
op.res.writeHead(frame.s ?? 502, headers)
|
|
692
|
+
} catch (error) {
|
|
693
|
+
this.log('relay', `bad upstream response for ${host.code}: ${error?.message ?? error}`)
|
|
694
|
+
host.httpOps.delete(frame.id)
|
|
695
|
+
this.disposeTimer(op.timer)
|
|
696
|
+
op.done = true
|
|
697
|
+
try { op.res.destroy() } catch { op.res.end() }
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
finishHttp(host, id) {
|
|
702
|
+
const op = host.httpOps.get(id)
|
|
703
|
+
if (!op) return
|
|
704
|
+
host.httpOps.delete(id)
|
|
705
|
+
this.disposeTimer(op.timer)
|
|
706
|
+
op.done = true
|
|
707
|
+
try {
|
|
708
|
+
if (op.htmlInject) {
|
|
709
|
+
const html = this.injectVisitorShim(Buffer.concat(op.parts).toString('utf8'), host.code, host.fileKey)
|
|
710
|
+
op.res.end(html)
|
|
711
|
+
} else {
|
|
712
|
+
op.res.end()
|
|
713
|
+
}
|
|
714
|
+
} catch { op.res.destroy() }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
routeText(host, frame) {
|
|
718
|
+
if (frame.t !== 'wdata-text' || typeof frame.text !== 'string') return
|
|
719
|
+
const splice = host.splices.get(frame.id)
|
|
720
|
+
// The visitor socket is a WebSocket: send a ws text frame, never raw bytes.
|
|
721
|
+
if (splice && !splice.closed) splice.socket.sendText(frame.text)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
routeBin(host, meta, payload) {
|
|
725
|
+
if (meta.t === 'hchunk') {
|
|
726
|
+
const op = host.httpOps.get(meta.id)
|
|
727
|
+
if (!op || op.res.writableEnded) return
|
|
728
|
+
if (op.htmlInject) {
|
|
729
|
+
op.htmlBytes += payload.length
|
|
730
|
+
if (op.htmlBytes <= MAX_HTML_INJECT_BYTES) {
|
|
731
|
+
op.parts.push(payload)
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
// 超限放弃注入:先冲掉已缓冲内容再直写当前块,保持字节顺序。
|
|
735
|
+
op.htmlInject = false
|
|
736
|
+
for (const part of op.parts) op.res.write(part)
|
|
737
|
+
op.parts = []
|
|
738
|
+
}
|
|
739
|
+
op.res.write(payload)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
if (meta.t === 'wdata') {
|
|
743
|
+
const splice = host.splices.get(meta.id)
|
|
744
|
+
// The visitor socket is a WebSocket: send a ws binary frame, never raw bytes.
|
|
745
|
+
if (splice && !splice.closed) splice.socket.sendRaw(payload)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
acceptWsSplice(req, rawSocket, head, host) {
|
|
750
|
+
if (this.closed || !host) { rawSocket.destroy(); return }
|
|
751
|
+
const origin = req.headers.origin
|
|
752
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
753
|
+
let sameOrigin = false
|
|
754
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
755
|
+
if (!sameOrigin) { rawSocket.destroy(); return }
|
|
756
|
+
}
|
|
757
|
+
if (host.splices.size >= this.maxVisitorsPerHost) {
|
|
758
|
+
this.log('relay', `splice rejected on ${host.code}: visitor cap`)
|
|
759
|
+
rawSocket.destroy()
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
let target = '/api/remote.mux'
|
|
763
|
+
try {
|
|
764
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
765
|
+
target = this.stripPrefix(url.pathname, host.code) + url.search
|
|
766
|
+
} catch { target = '/api/remote.mux' }
|
|
767
|
+
const subpath = target
|
|
768
|
+
const id = `s${++this.cmdSeq}`
|
|
769
|
+
const splicedHeaders = sanitizeUpgradeHeaders(req.headers, host.code)
|
|
770
|
+
function sanitizeUpgradeHeaders(headers, cd) {
|
|
771
|
+
const out = {}
|
|
772
|
+
const drop = new Set(['host', 'origin', 'referer', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol'])
|
|
773
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
774
|
+
const lower = name.toLowerCase()
|
|
775
|
+
if (drop.has(lower) || lower.startsWith('sec-')) continue
|
|
776
|
+
if (lower === 'cookie') {
|
|
777
|
+
const prefix = `r${cd}-`
|
|
778
|
+
const kept = []
|
|
779
|
+
for (const segment of String(value).split(';')) {
|
|
780
|
+
const pair = segment.trim()
|
|
781
|
+
const at = pair.indexOf('=')
|
|
782
|
+
if (at === -1) continue
|
|
783
|
+
const cname = pair.slice(0, at)
|
|
784
|
+
if (!cname.startsWith(prefix)) continue
|
|
785
|
+
kept.push(`${cname.slice(prefix.length)}=${pair.slice(at + 1)}`)
|
|
786
|
+
}
|
|
787
|
+
if (kept.length > 0) out.cookie = kept.join('; ')
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
out[lower] = Array.isArray(value) ? value.join(', ') : String(value)
|
|
791
|
+
}
|
|
792
|
+
return out
|
|
793
|
+
}
|
|
794
|
+
this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
|
|
795
|
+
const socket = adaptSocket(raw)
|
|
796
|
+
const splice = { raw, socket, closed: false }
|
|
797
|
+
host.splices.set(id, splice)
|
|
798
|
+
if (!host.socket) {
|
|
799
|
+
socket.close()
|
|
800
|
+
host.splices.delete(id)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
host.socket.send({ t: 'wopen', id, p: subpath, h: splicedHeaders })
|
|
804
|
+
this.log('relay', `splice ${id} opened on ${host.code}: ${subpath}`)
|
|
805
|
+
const offMessage = socket.onMessage((data) => {
|
|
806
|
+
try {
|
|
807
|
+
if (!host.socket) return
|
|
808
|
+
// The adapter hands through Buffers for binary and strings for text.
|
|
809
|
+
if (Buffer.isBuffer(data)) host.socket.sendRaw(encodeBin({ t: 'wdata', id }, data))
|
|
810
|
+
else host.socket.send({ t: 'wdata-text', id, text: data })
|
|
811
|
+
} catch (error) {
|
|
812
|
+
this.log('relay', `访客帧处理异常(已隔离): ${error?.message ?? error}`)
|
|
813
|
+
}
|
|
814
|
+
})
|
|
815
|
+
const offClose = socket.onClose(() => {
|
|
816
|
+
offMessage()
|
|
817
|
+
offClose()
|
|
818
|
+
if (splice.closed) return
|
|
819
|
+
splice.closed = true
|
|
820
|
+
host.splices.delete(id)
|
|
821
|
+
if (host.socket) host.socket.send({ t: 'wclose', id, code: 1001, reason: '' })
|
|
822
|
+
this.log('relay', `splice ${id} closed on ${host.code}`)
|
|
823
|
+
})
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
routeSpliceControl(host, frame) {
|
|
828
|
+
const splice = host.splices.get(frame.id)
|
|
829
|
+
if (!splice) return
|
|
830
|
+
if (frame.t === 'wfail') {
|
|
831
|
+
splice.closed = true
|
|
832
|
+
host.splices.delete(frame.id)
|
|
833
|
+
try { splice.socket.close(1014, 'upstream failed') } catch { splice.raw.destroy() }
|
|
834
|
+
this.log('relay', `splice ${frame.id} failed: ${frame.error ?? 'unknown'}`)
|
|
835
|
+
}
|
|
836
|
+
// wok needs no relay-side action; splice is already bidirectional.
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
finishSplice(host, frame) {
|
|
840
|
+
const splice = host.splices.get(frame.id)
|
|
841
|
+
if (!splice) return
|
|
842
|
+
splice.closed = true
|
|
843
|
+
host.splices.delete(frame.id)
|
|
844
|
+
try { splice.socket.close(frame.code ?? 1000, frame.reason ?? '') } catch { try { splice.raw.destroy() } catch { /* gone */ } }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/* ── console mode ───────────────────────────────────────────────────── */
|
|
848
|
+
|
|
849
|
+
serveConsole(req, res, code, host) {
|
|
850
|
+
let pathname = ''
|
|
851
|
+
try { pathname = new URL(req.url ?? '/', 'http://localhost').pathname } catch { pathname = '/' }
|
|
852
|
+
const send = (status, body, type) => {
|
|
853
|
+
res.writeHead(status, {
|
|
854
|
+
'Content-Type': type,
|
|
855
|
+
'Cache-Control': 'no-store',
|
|
856
|
+
'X-Content-Type-Options': 'nosniff',
|
|
857
|
+
})
|
|
858
|
+
res.end(body)
|
|
859
|
+
}
|
|
860
|
+
if (pathname !== `/${code}`) {
|
|
861
|
+
send(404, JSON.stringify({ ok: false, error: 'not found' }) + '\n', 'application/json; charset=utf-8')
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
865
|
+
send(405, JSON.stringify({ ok: false, error: 'method not allowed' }) + '\n', 'application/json; charset=utf-8')
|
|
866
|
+
return
|
|
867
|
+
}
|
|
868
|
+
send(200, this.consoleHtml({ code, offline: !host.socket }), 'text/html; charset=utf-8')
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
acceptVisitor(req, rawSocket, head, host) {
|
|
872
|
+
if (this.closed || !host) { rawSocket.destroy(); return }
|
|
873
|
+
// Same-origin browsers only; scripts without Origin pass (curl, CLI).
|
|
874
|
+
const origin = req.headers.origin
|
|
875
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
876
|
+
let sameOrigin = false
|
|
877
|
+
try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
|
|
878
|
+
if (!sameOrigin) { rawSocket.destroy(); return }
|
|
879
|
+
}
|
|
880
|
+
if (host.visitors.size >= this.maxVisitorsPerHost) {
|
|
881
|
+
this.log('relay', `visitor rejected on ${host.code}: visitor cap`)
|
|
882
|
+
rawSocket.destroy()
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
this.wss.handleUpgrade(req, rawSocket, head, (raw) => {
|
|
886
|
+
const socket = adaptSocket(raw)
|
|
887
|
+
const visitorId = 'v-' + randomUUID().slice(0, 8)
|
|
888
|
+
host.visitors.set(visitorId, { socket, connectedAt: Date.now() })
|
|
889
|
+
this.log('relay', `visitor ${visitorId} connected to ${host.code}`)
|
|
890
|
+
// Engine composes the welcome payload; relay forwards it as-is.
|
|
891
|
+
this.toHost(host, 'visitor.hello', visitorId, undefined, (frame) => {
|
|
892
|
+
if (frame.ok) {
|
|
893
|
+
socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, host: frame.data.host, sessions: frame.data.sessions })
|
|
894
|
+
} else {
|
|
895
|
+
socket.send({ t: 'welcome', v: PROTOCOL_VERSION, visitor: visitorId, error: frame.error })
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
let visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
|
|
899
|
+
const refreshIdle = () => {
|
|
900
|
+
this.disposeTimer(visitorIdleTimer)
|
|
901
|
+
visitorIdleTimer = this.later(() => socket.close(), VISITOR_IDLE_MS)
|
|
902
|
+
}
|
|
903
|
+
const offMessage = socket.onMessage((data) => {
|
|
904
|
+
if (typeof data !== 'string') return
|
|
905
|
+
refreshIdle()
|
|
906
|
+
const frame = parseFrame(data, 64 * 1024)
|
|
907
|
+
if (!frame || frame.t !== 'cmd' || typeof frame.id !== 'string') return
|
|
908
|
+
if (VISITOR_COMMANDS.has(frame.cmd)) {
|
|
909
|
+
this.toHost(host, frame.cmd, visitorId, frame.p, (result) => {
|
|
910
|
+
socket.send({ t: 'res', id: frame.id, ok: result.ok, ...(result.ok ? { data: result.data } : { error: result.error }) })
|
|
911
|
+
})
|
|
912
|
+
} else {
|
|
913
|
+
socket.send({ t: 'res', id: frame.id, ok: false, error: 'unsupported command' })
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
const offClose = socket.onClose(() => {
|
|
917
|
+
this.disposeTimer(visitorIdleTimer)
|
|
918
|
+
offMessage()
|
|
919
|
+
offClose()
|
|
920
|
+
host.visitors.delete(visitorId)
|
|
921
|
+
if (host.socket) this.toHost(host, 'visitor.gone', visitorId, undefined, () => {})
|
|
922
|
+
this.log('relay', `visitor ${visitorId} disconnected from ${host.code}`)
|
|
923
|
+
})
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
toHost(host, cmd, visitorId, p, onResult) {
|
|
928
|
+
const socket = host.socket
|
|
929
|
+
if (!socket) return onResult({ ok: false, error: '宿主机离线' })
|
|
930
|
+
const id = `c${++this.cmdSeq}`
|
|
931
|
+
host.pending.set(id, { onResult, timer: this.later(() => {
|
|
932
|
+
host.pending.delete(id)
|
|
933
|
+
onResult({ ok: false, error: '宿主机响应超时' })
|
|
934
|
+
}, 20_000) })
|
|
935
|
+
socket.send({ t: 'cmd', id, from: visitorId, cmd, ...(p === undefined ? {} : { p }) })
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
routeResult(host, frame) {
|
|
939
|
+
const entry = host.pending.get(frame.id)
|
|
940
|
+
if (!entry) return
|
|
941
|
+
host.pending.delete(frame.id)
|
|
942
|
+
this.disposeTimer(entry.timer)
|
|
943
|
+
entry.onResult({ ok: frame.ok === true, data: frame.data, error: frame.error })
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
routePush(host, frame) {
|
|
947
|
+
if (typeof frame.to !== 'string') return
|
|
948
|
+
const visitor = host.visitors.get(frame.to)
|
|
949
|
+
if (visitor) visitor.socket.send({ t: 'push', f: frame.f })
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
detachAll() {
|
|
953
|
+
this.closed = true
|
|
954
|
+
for (const timer of this.timers) clearTimeout(timer)
|
|
955
|
+
this.timers.clear()
|
|
956
|
+
for (const hostId of [...this.hosts.keys()]) this.removeHost(hostId, 'relay shutting down')
|
|
957
|
+
this.wss.close()
|
|
958
|
+
this.hostWss.close()
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
status() {
|
|
962
|
+
return {
|
|
963
|
+
mode: this.mode,
|
|
964
|
+
hosts: [...this.hosts.values()].map((host) => ({
|
|
965
|
+
hostId: host.hostId,
|
|
966
|
+
code: host.code,
|
|
967
|
+
name: host.name,
|
|
968
|
+
dsh: host.dsh,
|
|
969
|
+
online: host.socket !== undefined,
|
|
970
|
+
visitors: this.mode === 'gui' ? host.splices.size : host.visitors.size,
|
|
971
|
+
createdAt: host.createdAt,
|
|
972
|
+
})),
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export { PROTOCOL_VERSION }
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Origin-level service worker served at /dsh-remote-sw.js. It reads its own
|
|
981
|
+
* scriptURL query for the code and, from then on, intercepts navigations to
|
|
982
|
+
* the bare origin (which belong to the relay's authenticated GUI and 401 for
|
|
983
|
+
* visitors) and serves the tunnel under /<code>/ instead — the address bar
|
|
984
|
+
* keeps showing the bare origin. Carriers of the relay server's own session
|
|
985
|
+
* cookie (`dsh-auth-…`) pass through untouched, so the relay owner's GUI is
|
|
986
|
+
* never hijacked, and `?gui` is a manual escape.
|
|
987
|
+
*/
|
|
988
|
+
export function serviceWorkerScript() {
|
|
989
|
+
return `/* dsh-remote visitor restore worker */
|
|
990
|
+
var CODE='';
|
|
991
|
+
try{CODE=new URL(self.location.href).searchParams.get('code')||''}catch(e){}
|
|
992
|
+
self.addEventListener('install',function(e){self.skipWaiting()});
|
|
993
|
+
self.addEventListener('activate',function(e){e.waitUntil(self.clients.claim())});
|
|
994
|
+
self.addEventListener('fetch',function(event){
|
|
995
|
+
try{
|
|
996
|
+
var req=event.request;
|
|
997
|
+
if(req.mode!=='navigate')return;
|
|
998
|
+
var url=new URL(req.url);
|
|
999
|
+
if(url.origin!==self.location.origin)return;
|
|
1000
|
+
if((url.search||'').indexOf('gui')!==-1)return;
|
|
1001
|
+
if((req.headers.get('cookie')||'').indexOf('dsh-auth-')!==-1)return;
|
|
1002
|
+
if(url.pathname!=='/'&&url.pathname!=='')return;
|
|
1003
|
+
if(!/^[1-9]\\d{7}$/.test(CODE))return;
|
|
1004
|
+
event.respondWith((async function(){
|
|
1005
|
+
var up;
|
|
1006
|
+
try{up=await fetch('/'+CODE+'/',{cache:'no-store'})}catch(e){return}
|
|
1007
|
+
var ct=up.headers.get('content-type')||'';
|
|
1008
|
+
if(ct.indexOf('text/html')===-1)return up;
|
|
1009
|
+
var text=await up.text();
|
|
1010
|
+
if(text.indexOf('__DSH_REMOTE_VISITOR__')===-1)return fetch(req);
|
|
1011
|
+
return new Response(text,{status:up.status,statusText:up.statusText,headers:up.headers});
|
|
1012
|
+
})());
|
|
1013
|
+
}catch(e){}
|
|
1014
|
+
});
|
|
1015
|
+
`
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
export function serveServiceWorker(req, res) {
|
|
1019
|
+
res.writeHead(200, {
|
|
1020
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
1021
|
+
'Cache-Control': 'no-store',
|
|
1022
|
+
'Service-Worker-Allowed': '/',
|
|
1023
|
+
})
|
|
1024
|
+
res.end(serviceWorkerScript())
|
|
1025
|
+
}
|