dsh-remote-plugin 0.6.8 → 0.6.9
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/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +26 -11
- package/gateway.cjs +494 -87
- package/index.mjs +12 -2
- package/package.json +1 -1
- package/public/app.js +147 -59
- package/public/desktop/desktop.js +144 -56
- package/public/update.json +8 -4
- package/public/version.json +1 -1
package/gateway.cjs
CHANGED
|
@@ -45,13 +45,37 @@ const ROOT = __dirname
|
|
|
45
45
|
const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
46
46
|
const PORT = Number(process.env.PORT) || 8787
|
|
47
47
|
const HOST = process.env.HOST || '0.0.0.0'
|
|
48
|
-
|
|
48
|
+
|
|
49
|
+
function durationEnv(name, fallback, min, max) {
|
|
50
|
+
const raw = process.env[name]
|
|
51
|
+
if (raw === undefined || raw === '') return fallback
|
|
52
|
+
const value = Number(raw)
|
|
53
|
+
if (!Number.isFinite(value)) return fallback
|
|
54
|
+
return Math.min(max, Math.max(min, Math.round(value)))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 远程/VPN 用户的 RTT 和短暂抖动明显高于同机连接,默认使用 30s Ping、90s
|
|
58
|
+
// Pong 等待;关闭心跳时才退回到可选的硬空闲超时。0 是明确的禁用值。
|
|
59
|
+
const WS_PING_MS = durationEnv('GATEWAY_WS_PING_MS', 30000, 0, 10 * 60 * 1000)
|
|
60
|
+
const WS_PONG_TIMEOUT_MS = durationEnv('GATEWAY_WS_PONG_TIMEOUT_MS', 90000, 1000, 15 * 60 * 1000)
|
|
61
|
+
const WS_IDLE_MS = durationEnv('GATEWAY_WS_IDLE_MS', 180000, 0, 24 * 60 * 60 * 1000)
|
|
62
|
+
const WS_UPGRADE_TIMEOUT_MS = durationEnv('GATEWAY_WS_UPGRADE_TIMEOUT_MS', 15000, 1000, 5 * 60 * 1000)
|
|
63
|
+
const UPSTREAM_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_UPSTREAM_TIMEOUT_MS', 30000, 1000, 10 * 60 * 1000)
|
|
49
64
|
const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
|
|
65
|
+
const UPSTREAM_TRANSPORT = UPSTREAM.protocol === 'https:' ? https : http
|
|
66
|
+
const UPSTREAM_PORT = Number(UPSTREAM.port) || (UPSTREAM.protocol === 'https:' ? 443 : 80)
|
|
67
|
+
const UPSTREAM_AUTHORITY = `${UPSTREAM.hostname}${UPSTREAM.port ? ':' + UPSTREAM.port : ''}`
|
|
68
|
+
const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/')
|
|
69
|
+
? String(process.env.DSH_HEALTH_PATH || '/')
|
|
70
|
+
: '/' + String(process.env.DSH_HEALTH_PATH)
|
|
50
71
|
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
|
|
51
72
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
52
73
|
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
53
74
|
const STARTED_AT = Date.now()
|
|
54
75
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
76
|
+
const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
|
|
77
|
+
const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
|
|
78
|
+
const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
|
|
55
79
|
|
|
56
80
|
// 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
|
|
57
81
|
const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
|
|
@@ -160,6 +184,8 @@ function loadToken() {
|
|
|
160
184
|
|
|
161
185
|
const TOKEN_FROM_ENV = !!process.env.TOKEN
|
|
162
186
|
let TOKEN = loadToken()
|
|
187
|
+
const WS_TICKET_TTL_MS = durationEnv('GATEWAY_WS_TICKET_TTL_MS', 90000, 10000, 10 * 60 * 1000)
|
|
188
|
+
const wsTickets = new Map()
|
|
163
189
|
|
|
164
190
|
/** 一键轮换令牌: 写回 TOKEN_FILE 并立即生效(旧令牌/旧连接全部失效)。 */
|
|
165
191
|
function rotateToken() {
|
|
@@ -172,6 +198,7 @@ function rotateToken() {
|
|
|
172
198
|
return { error: 'write-failed', detail: err.message }
|
|
173
199
|
}
|
|
174
200
|
TOKEN = next
|
|
201
|
+
wsTickets.clear()
|
|
175
202
|
return { ok: true, token: next }
|
|
176
203
|
}
|
|
177
204
|
|
|
@@ -182,8 +209,29 @@ function tokenOf(req, url) {
|
|
|
182
209
|
return url.searchParams.get('token')
|
|
183
210
|
}
|
|
184
211
|
|
|
185
|
-
function authorized(req, url) {
|
|
186
|
-
|
|
212
|
+
function authorized(req, url, options = {}) {
|
|
213
|
+
if (tokenOf(req, url) === TOKEN) return true
|
|
214
|
+
if (options.consumeTicket) {
|
|
215
|
+
const ticket = url.searchParams.get('ticket')
|
|
216
|
+
const record = ticket && wsTickets.get(ticket)
|
|
217
|
+
if (record && record.expiresAt > Date.now()) {
|
|
218
|
+
record.uses--
|
|
219
|
+
if (record.uses <= 0) wsTickets.delete(ticket)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
if (ticket) wsTickets.delete(ticket)
|
|
223
|
+
}
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function issueWsTicket() {
|
|
228
|
+
const now = Date.now()
|
|
229
|
+
for (const [ticket, record] of wsTickets) {
|
|
230
|
+
if (record.expiresAt <= now) wsTickets.delete(ticket)
|
|
231
|
+
}
|
|
232
|
+
const ticket = crypto.randomBytes(24).toString('base64url')
|
|
233
|
+
wsTickets.set(ticket, { expiresAt: now + WS_TICKET_TTL_MS, uses: 4 })
|
|
234
|
+
return { ticket, expiresAt: now + WS_TICKET_TTL_MS }
|
|
187
235
|
}
|
|
188
236
|
|
|
189
237
|
// ---------- 设备监控 ----------
|
|
@@ -193,6 +241,12 @@ const devices = new Map() // ip -> device
|
|
|
193
241
|
const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
194
242
|
let totalRequests = 0
|
|
195
243
|
let authFailures = 0
|
|
244
|
+
const runtimeState = {
|
|
245
|
+
uncaughtExceptions: 0,
|
|
246
|
+
unhandledRejections: 0,
|
|
247
|
+
lastErrorAt: 0,
|
|
248
|
+
lastError: '',
|
|
249
|
+
}
|
|
196
250
|
|
|
197
251
|
function pruneDevices(now = Date.now()) {
|
|
198
252
|
for (const [ip, d] of devices) {
|
|
@@ -228,19 +282,28 @@ function kindOf(req) {
|
|
|
228
282
|
function touchDevice(req, extra = {}) {
|
|
229
283
|
pruneDevices()
|
|
230
284
|
const ip = ipOf(req)
|
|
285
|
+
const clientId = String(extra.clientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
|
|
286
|
+
const deviceKey = clientId ? `${ip}|${clientId}` : ip
|
|
231
287
|
totalRequests++
|
|
232
|
-
let d = devices.get(
|
|
288
|
+
let d = devices.get(deviceKey)
|
|
233
289
|
if (!d) {
|
|
234
290
|
d = {
|
|
235
|
-
ip, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
|
|
236
|
-
requests: 0, authFailures: 0, channels: {}, sockets: new Set()
|
|
291
|
+
id: deviceKey, ip, clientId, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
|
|
292
|
+
requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
|
|
237
293
|
}
|
|
238
|
-
devices.set(
|
|
294
|
+
devices.set(deviceKey, d)
|
|
239
295
|
}
|
|
240
296
|
d.lastSeen = Date.now()
|
|
241
297
|
d.requests++
|
|
242
|
-
if (extra.channel)
|
|
243
|
-
|
|
298
|
+
if (extra.channel) {
|
|
299
|
+
d.channelCounts[extra.channel] = (d.channelCounts[extra.channel] || 0) + 1
|
|
300
|
+
d.channels[extra.channel] = true
|
|
301
|
+
}
|
|
302
|
+
if (extra.closeChannel) {
|
|
303
|
+
const count = Math.max(0, (d.channelCounts[extra.closeChannel] || 1) - 1)
|
|
304
|
+
d.channelCounts[extra.closeChannel] = count
|
|
305
|
+
d.channels[extra.closeChannel] = count > 0
|
|
306
|
+
}
|
|
244
307
|
if (extra.failedAuth) d.authFailures++
|
|
245
308
|
const marked = req.headers['x-dsh-remote-client']
|
|
246
309
|
if (marked) d.kind = marked
|
|
@@ -253,6 +316,8 @@ function deviceViews() {
|
|
|
253
316
|
return [...devices.values()]
|
|
254
317
|
.map(d => ({
|
|
255
318
|
ip: d.ip,
|
|
319
|
+
id: d.id,
|
|
320
|
+
clientId: d.clientId || '',
|
|
256
321
|
note: deviceNotes[d.ip] || '',
|
|
257
322
|
kind: d.kind,
|
|
258
323
|
ua: d.ua,
|
|
@@ -261,21 +326,25 @@ function deviceViews() {
|
|
|
261
326
|
requests: d.requests,
|
|
262
327
|
authFailures: d.authFailures,
|
|
263
328
|
channels: { ...d.channels },
|
|
329
|
+
channelCounts: { ...d.channelCounts },
|
|
264
330
|
online: Date.now() - d.lastSeen < 60_000
|
|
265
331
|
}))
|
|
266
332
|
.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
267
333
|
}
|
|
268
334
|
|
|
269
335
|
function kickDevice(ip) {
|
|
270
|
-
const
|
|
271
|
-
if (!
|
|
336
|
+
const targets = [...devices.values()].filter(d => d.id === ip || d.ip === ip)
|
|
337
|
+
if (!targets.length) return 0
|
|
272
338
|
let n = 0
|
|
273
|
-
for (const
|
|
274
|
-
|
|
275
|
-
|
|
339
|
+
for (const d of targets) {
|
|
340
|
+
for (const sock of d.sockets) {
|
|
341
|
+
try { sock.destroy() } catch {}
|
|
342
|
+
n++
|
|
343
|
+
}
|
|
344
|
+
d.sockets.clear()
|
|
345
|
+
d.channels = {}
|
|
346
|
+
d.channelCounts = {}
|
|
276
347
|
}
|
|
277
|
-
d.sockets.clear()
|
|
278
|
-
d.channels = {}
|
|
279
348
|
return n
|
|
280
349
|
}
|
|
281
350
|
|
|
@@ -414,10 +483,34 @@ function checkForUpdates(verbose) {
|
|
|
414
483
|
}
|
|
415
484
|
|
|
416
485
|
// ---------- CORS ----------
|
|
417
|
-
|
|
418
|
-
|
|
486
|
+
const CORS_ORIGINS = new Set(String(process.env.DSH_REMOTE_CORS_ORIGINS || '')
|
|
487
|
+
.split(',').map(v => v.trim()).filter(Boolean))
|
|
488
|
+
const BUILTIN_CORS_ORIGINS = new Set([
|
|
489
|
+
'capacitor://localhost',
|
|
490
|
+
'ionic://localhost',
|
|
491
|
+
'http://localhost',
|
|
492
|
+
'https://localhost',
|
|
493
|
+
])
|
|
494
|
+
|
|
495
|
+
function cors(res, req = res.req) {
|
|
496
|
+
const origin = String(req?.headers?.origin || '').trim()
|
|
497
|
+
let allowed = !origin
|
|
498
|
+
if (origin) {
|
|
499
|
+
allowed = CORS_ORIGINS.has('*') || CORS_ORIGINS.has(origin) || BUILTIN_CORS_ORIGINS.has(origin)
|
|
500
|
+
if (!allowed) {
|
|
501
|
+
try {
|
|
502
|
+
const originUrl = new URL(origin)
|
|
503
|
+
const requestHost = String(req?.headers?.host || '').toLowerCase()
|
|
504
|
+
const localhostApp = ['http:', 'https:', 'capacitor:', 'ionic:'].includes(originUrl.protocol) && originUrl.hostname === 'localhost'
|
|
505
|
+
allowed = localhostApp || ((originUrl.protocol === 'http:' || originUrl.protocol === 'https:') && originUrl.host.toLowerCase() === requestHost)
|
|
506
|
+
} catch {}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (allowed) res.setHeader('access-control-allow-origin', origin || '*')
|
|
510
|
+
res.setHeader('vary', 'Origin')
|
|
419
511
|
res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client')
|
|
420
512
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
513
|
+
res.setHeader('access-control-max-age', '600')
|
|
421
514
|
}
|
|
422
515
|
|
|
423
516
|
function readBody(req, maxBytes = 64 * 1024) {
|
|
@@ -524,15 +617,19 @@ async function serveDshControl(req, res, url) {
|
|
|
524
617
|
}
|
|
525
618
|
|
|
526
619
|
// ---------- 事件轮询缓冲 ----------
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
|
|
620
|
+
// 网关每个通道只维护一条到 DSH 的 mux/host WebSocket,同时把事件写入
|
|
621
|
+
// 内存环形缓冲并广播给已认证客户端;前端在 WebSocket 被隧道/受限网络
|
|
622
|
+
// 阻断时改走 GET /api/events.poll 增量拉取。
|
|
623
|
+
const EVENT_BUFFER_MAX = durationEnv('GATEWAY_EVENT_BUFFER_MAX', 1000, 100, 10000)
|
|
530
624
|
const EVENT_MAX_STRING = 16 * 1024
|
|
625
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
531
626
|
const eventBuffers = { mux: [], host: [] }
|
|
532
627
|
const eventNextSeq = { mux: 1, host: 1 }
|
|
628
|
+
const collectorClients = { mux: new Set(), host: new Set() }
|
|
629
|
+
const collectorReplay = { mux: new Map(), host: new Map() }
|
|
533
630
|
const eventCollectorState = {
|
|
534
|
-
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
|
|
535
|
-
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
|
|
631
|
+
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
632
|
+
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
|
|
536
633
|
}
|
|
537
634
|
|
|
538
635
|
/** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
|
|
@@ -550,12 +647,92 @@ function truncateEventValue(v, depth = 0) {
|
|
|
550
647
|
return v
|
|
551
648
|
}
|
|
552
649
|
|
|
553
|
-
function
|
|
650
|
+
function wsAccept(key) {
|
|
651
|
+
return crypto.createHash('sha1').update(String(key || '') + WS_GUID).digest('base64')
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function encodeWsText(text) {
|
|
655
|
+
const payload = Buffer.from(String(text), 'utf8')
|
|
656
|
+
if (payload.length < 126) return Buffer.concat([Buffer.from([0x81, payload.length]), payload])
|
|
657
|
+
if (payload.length < 65536) {
|
|
658
|
+
const header = Buffer.alloc(4)
|
|
659
|
+
header[0] = 0x81
|
|
660
|
+
header[1] = 126
|
|
661
|
+
header.writeUInt16BE(payload.length, 2)
|
|
662
|
+
return Buffer.concat([header, payload])
|
|
663
|
+
}
|
|
664
|
+
const header = Buffer.alloc(10)
|
|
665
|
+
header[0] = 0x81
|
|
666
|
+
header[1] = 127
|
|
667
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2)
|
|
668
|
+
return Buffer.concat([header, payload])
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function rememberCollectorReplay(kind, full, raw) {
|
|
672
|
+
const payload = full?.payload
|
|
673
|
+
if (!payload || typeof payload !== 'object') return
|
|
674
|
+
const replay = collectorReplay[kind]
|
|
675
|
+
let key = ''
|
|
676
|
+
if (payload.type === 'session/subscribed' && payload.sessionId) key = `session:${payload.sessionId}`
|
|
677
|
+
else if (payload.type === 'approval/requested' && payload.approvalId) key = `approval:${payload.approvalId}`
|
|
678
|
+
else if (payload.type === 'question/requested' && full.rpcId) key = `question:${full.rpcId}`
|
|
679
|
+
else if (payload.type === 'approval/resolved' && payload.approvalId) replay.delete(`approval:${payload.approvalId}`)
|
|
680
|
+
else if (payload.type === 'question/resolved' && payload.questionRpcId) replay.delete(`question:${payload.questionRpcId}`)
|
|
681
|
+
else if (payload.type === 'host/session-removed' && payload.sessionId) replay.delete(`session:${payload.sessionId}`)
|
|
682
|
+
if (!key) return
|
|
683
|
+
replay.delete(key)
|
|
684
|
+
replay.set(key, raw)
|
|
685
|
+
while (replay.size > 500) replay.delete(replay.keys().next().value)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function broadcastCollectorFrame(kind, raw) {
|
|
689
|
+
const state = eventCollectorState[kind]
|
|
690
|
+
const frame = encodeWsText(raw)
|
|
691
|
+
for (const socket of collectorClients[kind]) {
|
|
692
|
+
if (socket.destroyed || !socket.writable) {
|
|
693
|
+
collectorClients[kind].delete(socket)
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
try { socket.write(frame) } catch { try { socket.destroy() } catch {} }
|
|
697
|
+
}
|
|
698
|
+
state.clients = collectorClients[kind].size
|
|
699
|
+
state.framesBroadcast++
|
|
700
|
+
state.lastBroadcastAt = Date.now()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function pushEvent(kind, full, raw = JSON.stringify(full)) {
|
|
554
704
|
if (!eventBuffers[kind] || !full || typeof full !== 'object') return
|
|
555
705
|
if (eventCollectorState[kind]) eventCollectorState[kind].lastEventAt = Date.now()
|
|
556
706
|
const buf = eventBuffers[kind]
|
|
557
707
|
buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
|
|
558
708
|
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
709
|
+
rememberCollectorReplay(kind, full, raw)
|
|
710
|
+
broadcastCollectorFrame(kind, raw)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function serveWsTicket(req, res, url) {
|
|
714
|
+
if (req.method === 'OPTIONS') {
|
|
715
|
+
cors(res, req)
|
|
716
|
+
res.writeHead(204)
|
|
717
|
+
res.end()
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
721
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
722
|
+
res.end()
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
if (!authorized(req, url)) {
|
|
726
|
+
authFailures++
|
|
727
|
+
touchDevice(req, { failedAuth: true })
|
|
728
|
+
cors(res, req)
|
|
729
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
730
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
cors(res, req)
|
|
734
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
735
|
+
res.end(JSON.stringify({ ok: true, ...issueWsTicket() }))
|
|
559
736
|
}
|
|
560
737
|
|
|
561
738
|
function serveEventPoll(req, res, url) {
|
|
@@ -607,20 +784,42 @@ function startEventCollector(kind) {
|
|
|
607
784
|
let ws = null
|
|
608
785
|
let stopped = false
|
|
609
786
|
let retryTimer = null
|
|
610
|
-
|
|
787
|
+
let connectTimer = null
|
|
788
|
+
const scheme = UPSTREAM.protocol === 'https:' ? 'wss' : 'ws'
|
|
789
|
+
const url = `${scheme}://${UPSTREAM_AUTHORITY}/api/events.${kind}?client=web`
|
|
790
|
+
const schedule = () => {
|
|
791
|
+
if (stopped || retryTimer) return
|
|
792
|
+
const attempt = state.attempt++
|
|
793
|
+
const base = Math.min(1500 * Math.pow(2, attempt), 60000)
|
|
794
|
+
const delay = Math.round(base * (0.8 + Math.random() * 0.4))
|
|
795
|
+
retryTimer = setTimeout(() => { retryTimer = null; connect() }, delay)
|
|
796
|
+
retryTimer.unref?.()
|
|
797
|
+
}
|
|
611
798
|
const connect = () => {
|
|
612
799
|
if (stopped) return
|
|
613
800
|
try {
|
|
614
801
|
ws = new WebSocket(url)
|
|
615
|
-
} catch {
|
|
616
|
-
|
|
802
|
+
} catch (err) {
|
|
803
|
+
state.lastError = String(err?.message || err)
|
|
804
|
+
schedule()
|
|
617
805
|
return
|
|
618
806
|
}
|
|
807
|
+
const current = ws
|
|
808
|
+
connectTimer = setTimeout(() => {
|
|
809
|
+
if (ws === current && current.readyState === 0) {
|
|
810
|
+
state.lastError = 'websocket connect timeout'
|
|
811
|
+
try { current.close() } catch {}
|
|
812
|
+
}
|
|
813
|
+
}, WS_UPGRADE_TIMEOUT_MS)
|
|
814
|
+
connectTimer.unref?.()
|
|
619
815
|
ws.onopen = () => {
|
|
816
|
+
if (connectTimer) clearTimeout(connectTimer)
|
|
817
|
+
connectTimer = null
|
|
620
818
|
if (state) {
|
|
621
819
|
state.connected = true
|
|
622
820
|
state.lastConnectAt = Date.now()
|
|
623
821
|
state.lastError = ''
|
|
822
|
+
state.attempt = 0
|
|
624
823
|
}
|
|
625
824
|
if (stopped) { try { ws.close() } catch {} }
|
|
626
825
|
}
|
|
@@ -628,20 +827,24 @@ function startEventCollector(kind) {
|
|
|
628
827
|
if (stopped) return
|
|
629
828
|
try {
|
|
630
829
|
const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
|
|
631
|
-
pushEvent(kind, JSON.parse(data))
|
|
830
|
+
pushEvent(kind, JSON.parse(data), data)
|
|
632
831
|
} catch {}
|
|
633
832
|
}
|
|
634
833
|
ws.onclose = () => {
|
|
834
|
+
if (connectTimer) clearTimeout(connectTimer)
|
|
835
|
+
connectTimer = null
|
|
635
836
|
if (state) {
|
|
636
837
|
state.connected = false
|
|
637
838
|
state.reconnects++
|
|
839
|
+
state.lastCloseCode = Number(current?.closeCode) || 0
|
|
840
|
+
state.lastCloseReason = String(current?.closeReason || '')
|
|
638
841
|
}
|
|
639
842
|
ws = null
|
|
640
|
-
|
|
843
|
+
schedule()
|
|
641
844
|
}
|
|
642
845
|
ws.onerror = (err) => {
|
|
643
846
|
if (state) state.lastError = String(err?.message || 'websocket error')
|
|
644
|
-
try {
|
|
847
|
+
try { current.close() } catch {}
|
|
645
848
|
}
|
|
646
849
|
}
|
|
647
850
|
connect()
|
|
@@ -650,6 +853,9 @@ function startEventCollector(kind) {
|
|
|
650
853
|
close() {
|
|
651
854
|
stopped = true
|
|
652
855
|
clearTimeout(retryTimer)
|
|
856
|
+
clearTimeout(connectTimer)
|
|
857
|
+
retryTimer = null
|
|
858
|
+
connectTimer = null
|
|
653
859
|
try { ws?.close() } catch {}
|
|
654
860
|
}
|
|
655
861
|
}
|
|
@@ -730,14 +936,17 @@ function serveStats(req, res, url) {
|
|
|
730
936
|
res.end(JSON.stringify({ error: 'sessionId 与 event 必填' }))
|
|
731
937
|
return
|
|
732
938
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
939
|
+
// 统计是旁路能力,不能让同步落盘阻塞插件的实时事件链路;
|
|
940
|
+
// 先确认已入队,具体聚合由 StatsStore 自己串行处理。
|
|
941
|
+
setImmediate(() => {
|
|
942
|
+
statsStore.ingestEvent(sessionId, event, payload.fallbackModel).then((out) => {
|
|
943
|
+
if (out.gap) scanStatsOnce(3000)
|
|
944
|
+
}).catch((err) => {
|
|
945
|
+
console.warn('[stats] 实时事件落盘失败: ' + (err?.message || err))
|
|
946
|
+
})
|
|
740
947
|
})
|
|
948
|
+
res.writeHead(202, { 'content-type': 'application/json; charset=utf-8' })
|
|
949
|
+
res.end(JSON.stringify({ ok: true, queued: true }))
|
|
741
950
|
})
|
|
742
951
|
return
|
|
743
952
|
}
|
|
@@ -938,9 +1147,9 @@ function serveStatic(req, res, url) {
|
|
|
938
1147
|
|
|
939
1148
|
// ---------- 管理 API ----------
|
|
940
1149
|
function upstreamReachable(cb) {
|
|
941
|
-
const req =
|
|
1150
|
+
const req = UPSTREAM_TRANSPORT.request({
|
|
942
1151
|
hostname: UPSTREAM.hostname,
|
|
943
|
-
port:
|
|
1152
|
+
port: UPSTREAM_PORT,
|
|
944
1153
|
method: 'GET',
|
|
945
1154
|
path: '/health',
|
|
946
1155
|
timeout: 1500
|
|
@@ -1904,9 +2113,10 @@ function proxyApi(req, res, url) {
|
|
|
1904
2113
|
headers.authorization = 'Bearer ' + TOKEN
|
|
1905
2114
|
}
|
|
1906
2115
|
|
|
1907
|
-
|
|
2116
|
+
let responseDone = false
|
|
2117
|
+
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
1908
2118
|
hostname: UPSTREAM.hostname,
|
|
1909
|
-
port:
|
|
2119
|
+
port: UPSTREAM_PORT,
|
|
1910
2120
|
method: req.method,
|
|
1911
2121
|
path: url.pathname + url.search,
|
|
1912
2122
|
headers
|
|
@@ -1915,10 +2125,21 @@ function proxyApi(req, res, url) {
|
|
|
1915
2125
|
delete out['content-length']
|
|
1916
2126
|
cors(res)
|
|
1917
2127
|
res.writeHead(upstreamRes.statusCode || 502, out)
|
|
2128
|
+
upstreamRes.on('error', (err) => {
|
|
2129
|
+
if (responseDone || res.destroyed) return
|
|
2130
|
+
responseDone = true
|
|
2131
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
2132
|
+
res.end(JSON.stringify({ error: 'upstream-response-error', detail: String(err.message || err) }))
|
|
2133
|
+
})
|
|
1918
2134
|
upstreamRes.pipe(res)
|
|
1919
2135
|
})
|
|
1920
2136
|
|
|
2137
|
+
upstreamReq.setTimeout(UPSTREAM_REQUEST_TIMEOUT_MS, () => {
|
|
2138
|
+
upstreamReq.destroy(new Error('upstream request timeout'))
|
|
2139
|
+
})
|
|
1921
2140
|
upstreamReq.on('error', (err) => {
|
|
2141
|
+
if (responseDone || res.destroyed) return
|
|
2142
|
+
responseDone = true
|
|
1922
2143
|
cors(res)
|
|
1923
2144
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
1924
2145
|
res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(err.message || err) }))
|
|
@@ -1926,20 +2147,31 @@ function proxyApi(req, res, url) {
|
|
|
1926
2147
|
|
|
1927
2148
|
req.on('error', () => { upstreamReq.destroy() })
|
|
1928
2149
|
req.on('aborted', () => { upstreamReq.destroy() })
|
|
2150
|
+
res.on('close', () => {
|
|
2151
|
+
if (!res.writableEnded) upstreamReq.destroy()
|
|
2152
|
+
})
|
|
1929
2153
|
req.pipe(upstreamReq)
|
|
1930
2154
|
}
|
|
1931
2155
|
|
|
1932
2156
|
// ---------- 其它 ----------
|
|
1933
2157
|
async function serveHealth(res) {
|
|
1934
2158
|
let upstreamOk = false
|
|
2159
|
+
let upstreamReachable = false
|
|
2160
|
+
let upstreamStatus = 0
|
|
2161
|
+
let upstreamError = ''
|
|
2162
|
+
let timer = null
|
|
1935
2163
|
try {
|
|
1936
2164
|
const ctrl = new AbortController()
|
|
1937
|
-
|
|
1938
|
-
const
|
|
1939
|
-
|
|
2165
|
+
timer = setTimeout(() => ctrl.abort(), 5000)
|
|
2166
|
+
const probeUrl = new URL(DSH_HEALTH_PATH, UPSTREAM).toString()
|
|
2167
|
+
const probe = await fetch(probeUrl, { signal: ctrl.signal, cache: 'no-store' })
|
|
2168
|
+
upstreamReachable = true
|
|
2169
|
+
upstreamStatus = probe.status
|
|
1940
2170
|
upstreamOk = probe.ok
|
|
1941
|
-
} catch {
|
|
1942
|
-
|
|
2171
|
+
} catch (err) {
|
|
2172
|
+
upstreamError = String(err?.message || err || '')
|
|
2173
|
+
} finally {
|
|
2174
|
+
if (timer) clearTimeout(timer)
|
|
1943
2175
|
}
|
|
1944
2176
|
cors(res)
|
|
1945
2177
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1949,8 +2181,13 @@ async function serveHealth(res) {
|
|
|
1949
2181
|
version: VERSION,
|
|
1950
2182
|
pid: process.pid,
|
|
1951
2183
|
upstream: UPSTREAM.origin,
|
|
2184
|
+
upstreamProbe: DSH_HEALTH_PATH,
|
|
1952
2185
|
upstreamOk,
|
|
2186
|
+
upstreamReachable,
|
|
2187
|
+
upstreamStatus,
|
|
2188
|
+
...(upstreamError ? { upstreamError } : {}),
|
|
1953
2189
|
events: eventCollectorState,
|
|
2190
|
+
runtime: runtimeState,
|
|
1954
2191
|
}))
|
|
1955
2192
|
}
|
|
1956
2193
|
|
|
@@ -1972,6 +2209,7 @@ const server = http.createServer((req, res) => {
|
|
|
1972
2209
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
1973
2210
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
1974
2211
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
2212
|
+
if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
|
|
1975
2213
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
1976
2214
|
if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
|
|
1977
2215
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
@@ -1990,38 +2228,200 @@ const server = http.createServer((req, res) => {
|
|
|
1990
2228
|
} catch {}
|
|
1991
2229
|
}
|
|
1992
2230
|
})
|
|
2231
|
+
// 长连接与 VPN 上传需要比 Node 默认值更宽松的请求窗口;WebSocket upgrade
|
|
2232
|
+
// 完成后不受 HTTP requestTimeout 影响,升级握手另由 WS_UPGRADE_TIMEOUT_MS 管理。
|
|
2233
|
+
server.requestTimeout = HTTP_REQUEST_TIMEOUT_MS
|
|
2234
|
+
server.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
|
|
2235
|
+
server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS
|
|
2236
|
+
server.timeout = 0
|
|
1993
2237
|
|
|
1994
2238
|
// 最后一层护栏: 任何未捕获异常只记录不退出(网关单点服务, 不能因单请求竞态离线)
|
|
1995
2239
|
process.on('uncaughtException', (err) => {
|
|
2240
|
+
runtimeState.uncaughtExceptions++
|
|
2241
|
+
runtimeState.lastErrorAt = Date.now()
|
|
2242
|
+
runtimeState.lastError = String(err?.message || err || 'uncaught exception')
|
|
1996
2243
|
try { console.error('[uncaughtException]', err?.stack || String(err)) } catch {}
|
|
1997
2244
|
})
|
|
1998
2245
|
process.on('unhandledRejection', (err) => {
|
|
2246
|
+
runtimeState.unhandledRejections++
|
|
2247
|
+
runtimeState.lastErrorAt = Date.now()
|
|
2248
|
+
runtimeState.lastError = String(err?.message || err || 'unhandled rejection')
|
|
1999
2249
|
try { console.error('[unhandledRejection]', err?.stack || String(err)) } catch {}
|
|
2000
2250
|
})
|
|
2001
2251
|
|
|
2252
|
+
function wsPingFrame(masked) {
|
|
2253
|
+
if (!masked) return Buffer.from([0x89, 0x00])
|
|
2254
|
+
const mask = crypto.randomBytes(4)
|
|
2255
|
+
return Buffer.concat([Buffer.from([0x89, 0x80]), mask])
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* 原始 TCP 透传也要维护 WebSocket 控制帧活性:
|
|
2260
|
+
* - 浏览器侧收到网关的未掩码 Ping 后会自动回 Pong;
|
|
2261
|
+
* - DSH 侧作为 WebSocket 服务端会自动回网关的掩码 Ping;
|
|
2262
|
+
* - 业务事件可以长时间静默, 不能再把“无业务数据”当作死连接。
|
|
2263
|
+
*/
|
|
2264
|
+
function startWsHeartbeat(clientSocket, upstreamSocket, destroyBoth) {
|
|
2265
|
+
let clientActivity = Date.now()
|
|
2266
|
+
let upstreamActivity = Date.now()
|
|
2267
|
+
let lastClientPing = 0
|
|
2268
|
+
let lastUpstreamPing = 0
|
|
2269
|
+
let timer = null
|
|
2270
|
+
|
|
2271
|
+
const touchClient = () => { clientActivity = Date.now() }
|
|
2272
|
+
const touchUpstream = () => { upstreamActivity = Date.now() }
|
|
2273
|
+
clientSocket.on('data', touchClient)
|
|
2274
|
+
upstreamSocket.on('data', touchUpstream)
|
|
2275
|
+
|
|
2276
|
+
const intervalMs = WS_PING_MS > 0
|
|
2277
|
+
? Math.max(100, Math.min(Math.round(WS_PING_MS / 4), 5000))
|
|
2278
|
+
: WS_IDLE_MS > 0 ? Math.max(1000, Math.min(Math.round(WS_IDLE_MS / 4), 5000)) : 0
|
|
2279
|
+
if (intervalMs > 0) {
|
|
2280
|
+
timer = setInterval(() => {
|
|
2281
|
+
const now = Date.now()
|
|
2282
|
+
if (WS_PING_MS > 0) {
|
|
2283
|
+
if (now - clientActivity > WS_PONG_TIMEOUT_MS || now - upstreamActivity > WS_PONG_TIMEOUT_MS) {
|
|
2284
|
+
destroyBoth()
|
|
2285
|
+
return
|
|
2286
|
+
}
|
|
2287
|
+
if (now - lastClientPing >= WS_PING_MS && !clientSocket.destroyed) {
|
|
2288
|
+
clientSocket.write(wsPingFrame(false))
|
|
2289
|
+
lastClientPing = now
|
|
2290
|
+
}
|
|
2291
|
+
if (now - lastUpstreamPing >= WS_PING_MS && !upstreamSocket.destroyed) {
|
|
2292
|
+
upstreamSocket.write(wsPingFrame(true))
|
|
2293
|
+
lastUpstreamPing = now
|
|
2294
|
+
}
|
|
2295
|
+
} else if ((now - clientActivity > WS_IDLE_MS) || (now - upstreamActivity > WS_IDLE_MS)) {
|
|
2296
|
+
destroyBoth()
|
|
2297
|
+
}
|
|
2298
|
+
}, intervalMs)
|
|
2299
|
+
timer.unref?.()
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
return () => {
|
|
2303
|
+
if (timer) clearInterval(timer)
|
|
2304
|
+
timer = null
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function startWsClientHeartbeat(socket, destroy) {
|
|
2309
|
+
let activity = Date.now()
|
|
2310
|
+
let lastPing = 0
|
|
2311
|
+
let timer = null
|
|
2312
|
+
socket.on('data', () => { activity = Date.now() })
|
|
2313
|
+
const intervalMs = WS_PING_MS > 0
|
|
2314
|
+
? Math.max(100, Math.min(Math.round(WS_PING_MS / 4), 5000))
|
|
2315
|
+
: WS_IDLE_MS > 0 ? Math.max(1000, Math.min(Math.round(WS_IDLE_MS / 4), 5000)) : 0
|
|
2316
|
+
if (intervalMs > 0) {
|
|
2317
|
+
timer = setInterval(() => {
|
|
2318
|
+
const now = Date.now()
|
|
2319
|
+
if (WS_PING_MS > 0) {
|
|
2320
|
+
if (now - activity > WS_PONG_TIMEOUT_MS) {
|
|
2321
|
+
destroy()
|
|
2322
|
+
return
|
|
2323
|
+
}
|
|
2324
|
+
if (now - lastPing >= WS_PING_MS && !socket.destroyed) {
|
|
2325
|
+
socket.write(wsPingFrame(false))
|
|
2326
|
+
lastPing = now
|
|
2327
|
+
}
|
|
2328
|
+
} else if (now - activity > WS_IDLE_MS) {
|
|
2329
|
+
destroy()
|
|
2330
|
+
}
|
|
2331
|
+
}, intervalMs)
|
|
2332
|
+
timer.unref?.()
|
|
2333
|
+
}
|
|
2334
|
+
return () => {
|
|
2335
|
+
if (timer) clearInterval(timer)
|
|
2336
|
+
timer = null
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function acceptCollectorClient(req, socket, head, kind, device) {
|
|
2341
|
+
const key = req.headers['sec-websocket-key']
|
|
2342
|
+
if (!key) {
|
|
2343
|
+
if (device) {
|
|
2344
|
+
device.sockets.delete(socket)
|
|
2345
|
+
const count = Math.max(0, (device.channelCounts[kind] || 1) - 1)
|
|
2346
|
+
device.channelCounts[kind] = count
|
|
2347
|
+
device.channels[kind] = count > 0
|
|
2348
|
+
}
|
|
2349
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
2350
|
+
return
|
|
2351
|
+
}
|
|
2352
|
+
socket.write(
|
|
2353
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
2354
|
+
'Upgrade: websocket\r\n' +
|
|
2355
|
+
'Connection: Upgrade\r\n' +
|
|
2356
|
+
`Sec-WebSocket-Accept: ${wsAccept(key)}\r\n\r\n`
|
|
2357
|
+
)
|
|
2358
|
+
if (head?.length) socket.unshift(head)
|
|
2359
|
+
socket.setNoDelay(true)
|
|
2360
|
+
collectorClients[kind].add(socket)
|
|
2361
|
+
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
2362
|
+
for (const raw of collectorReplay[kind].values()) {
|
|
2363
|
+
if (socket.destroyed || !socket.writable) break
|
|
2364
|
+
try { socket.write(encodeWsText(raw)) } catch { break }
|
|
2365
|
+
}
|
|
2366
|
+
const stopHeartbeat = startWsClientHeartbeat(socket, () => socket.destroy())
|
|
2367
|
+
const release = () => {
|
|
2368
|
+
stopHeartbeat()
|
|
2369
|
+
collectorClients[kind].delete(socket)
|
|
2370
|
+
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
2371
|
+
if (device) {
|
|
2372
|
+
device.sockets.delete(socket)
|
|
2373
|
+
const count = Math.max(0, (device.channelCounts[kind] || 1) - 1)
|
|
2374
|
+
device.channelCounts[kind] = count
|
|
2375
|
+
device.channels[kind] = count > 0
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
socket.once('close', release)
|
|
2379
|
+
socket.once('error', () => { try { socket.destroy() } catch {} })
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function writeUpgradeFailure(socket, statusCode, statusMessage) {
|
|
2383
|
+
if (socket.destroyed || !socket.writable) return
|
|
2384
|
+
const text = `upstream websocket upgrade failed: ${statusCode} ${statusMessage || ''}`.trim()
|
|
2385
|
+
const body = Buffer.from(text + '\n')
|
|
2386
|
+
const headers =
|
|
2387
|
+
`HTTP/1.1 ${statusCode} ${statusMessage || 'Bad Gateway'}\r\n` +
|
|
2388
|
+
'Connection: close\r\n' +
|
|
2389
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
2390
|
+
`Content-Length: ${body.length}\r\n\r\n`
|
|
2391
|
+
socket.end(Buffer.concat([Buffer.from(headers), body]))
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2002
2394
|
server.on('upgrade', (req, socket, head) => {
|
|
2003
2395
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
2004
2396
|
if (!url.pathname.startsWith('/api/')) {
|
|
2005
2397
|
socket.destroy()
|
|
2006
2398
|
return
|
|
2007
2399
|
}
|
|
2008
|
-
const ok = authorized(req, url)
|
|
2400
|
+
const ok = authorized(req, url, { consumeTicket: true })
|
|
2009
2401
|
const channel = url.pathname.includes('events.mux') ? 'mux' : url.pathname.includes('events.host') ? 'host' : null
|
|
2010
|
-
const
|
|
2011
|
-
|
|
2012
|
-
const
|
|
2013
|
-
d.sockets.delete(socket)
|
|
2014
|
-
if (channel) d.channels[channel] = false
|
|
2015
|
-
try { socket.destroy() } catch {}
|
|
2016
|
-
}
|
|
2017
|
-
socket.on('close', release)
|
|
2402
|
+
const clientId = url.searchParams.get('clientId') || ''
|
|
2403
|
+
const deviceExtra = ok && channel ? { channel, clientId } : { failedAuth: !ok, clientId }
|
|
2404
|
+
const d = touchDevice(req, deviceExtra)
|
|
2018
2405
|
if (!ok) {
|
|
2019
2406
|
authFailures++
|
|
2020
2407
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
|
2021
|
-
|
|
2408
|
+
try { socket.destroy() } catch {}
|
|
2022
2409
|
return
|
|
2023
2410
|
}
|
|
2024
2411
|
|
|
2412
|
+
if (channel) {
|
|
2413
|
+
d.sockets.add(socket)
|
|
2414
|
+
acceptCollectorClient(req, socket, head, channel, d)
|
|
2415
|
+
return
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
if (d) d.sockets.add(socket)
|
|
2419
|
+
const release = () => {
|
|
2420
|
+
d.sockets.delete(socket)
|
|
2421
|
+
try { socket.destroy() } catch {}
|
|
2422
|
+
}
|
|
2423
|
+
socket.once('close', release)
|
|
2424
|
+
|
|
2025
2425
|
const headers = {}
|
|
2026
2426
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
2027
2427
|
if (v === undefined) continue
|
|
@@ -2041,15 +2441,27 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
2041
2441
|
if (req.headers['sec-websocket-protocol']) headers['sec-websocket-protocol'] = req.headers['sec-websocket-protocol']
|
|
2042
2442
|
if (req.headers['sec-websocket-extensions']) headers['sec-websocket-extensions'] = req.headers['sec-websocket-extensions']
|
|
2043
2443
|
|
|
2044
|
-
|
|
2444
|
+
let upgraded = false
|
|
2445
|
+
let handshakeTimer = null
|
|
2446
|
+
const finishHandshake = () => {
|
|
2447
|
+
if (handshakeTimer) clearTimeout(handshakeTimer)
|
|
2448
|
+
handshakeTimer = null
|
|
2449
|
+
}
|
|
2450
|
+
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
2045
2451
|
hostname: UPSTREAM.hostname,
|
|
2046
|
-
port:
|
|
2452
|
+
port: UPSTREAM_PORT,
|
|
2047
2453
|
method: req.method,
|
|
2048
2454
|
path: url.pathname + url.search,
|
|
2049
2455
|
headers
|
|
2050
2456
|
})
|
|
2457
|
+
socket.once('close', () => {
|
|
2458
|
+
finishHandshake()
|
|
2459
|
+
if (!upgraded) upstreamReq.destroy()
|
|
2460
|
+
})
|
|
2051
2461
|
|
|
2052
2462
|
upstreamReq.on('upgrade', (upRes, upSocket, upHead) => {
|
|
2463
|
+
upgraded = true
|
|
2464
|
+
finishHandshake()
|
|
2053
2465
|
if (socket.destroyed) { upSocket.destroy(); return }
|
|
2054
2466
|
const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`]
|
|
2055
2467
|
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
@@ -2064,48 +2476,43 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
2064
2476
|
upSocket.setNoDelay(true)
|
|
2065
2477
|
upSocket.pipe(socket)
|
|
2066
2478
|
socket.pipe(upSocket)
|
|
2067
|
-
// 双向 idle 检测: 任一侧 60s 无数据即视为死连接, 同时销毁两侧
|
|
2068
|
-
let upIdle = null
|
|
2069
|
-
let clientIdle = null
|
|
2070
|
-
const clearIdle = () => {
|
|
2071
|
-
clearTimeout(upIdle)
|
|
2072
|
-
clearTimeout(clientIdle)
|
|
2073
|
-
upIdle = null
|
|
2074
|
-
clientIdle = null
|
|
2075
|
-
}
|
|
2076
2479
|
const destroyBoth = () => {
|
|
2077
|
-
|
|
2480
|
+
heartbeatStop()
|
|
2078
2481
|
upSocket.destroy()
|
|
2079
2482
|
socket.destroy()
|
|
2080
2483
|
}
|
|
2081
2484
|
const close = () => {
|
|
2082
|
-
|
|
2485
|
+
heartbeatStop()
|
|
2083
2486
|
upSocket.destroy()
|
|
2084
2487
|
socket.destroy()
|
|
2085
2488
|
}
|
|
2086
|
-
const
|
|
2087
|
-
clearTimeout(upIdle)
|
|
2088
|
-
upIdle = setTimeout(destroyBoth, WS_IDLE_MS)
|
|
2089
|
-
upIdle?.unref?.()
|
|
2090
|
-
}
|
|
2091
|
-
const touchClient = () => {
|
|
2092
|
-
clearTimeout(clientIdle)
|
|
2093
|
-
clientIdle = setTimeout(destroyBoth, WS_IDLE_MS)
|
|
2094
|
-
clientIdle?.unref?.()
|
|
2095
|
-
}
|
|
2096
|
-
upSocket.on('data', touchUp)
|
|
2097
|
-
socket.on('data', touchClient)
|
|
2098
|
-
touchUp()
|
|
2099
|
-
touchClient()
|
|
2489
|
+
const heartbeatStop = startWsHeartbeat(socket, upSocket, destroyBoth)
|
|
2100
2490
|
upSocket.on('error', close)
|
|
2101
2491
|
socket.on('error', close)
|
|
2102
|
-
upSocket.on('close', () => {
|
|
2103
|
-
socket.on('close', () => {
|
|
2492
|
+
upSocket.on('close', () => { heartbeatStop(); if (!socket.destroyed) socket.end() })
|
|
2493
|
+
socket.on('close', () => { heartbeatStop(); if (!upSocket.destroyed) upSocket.end() })
|
|
2104
2494
|
})
|
|
2105
2495
|
|
|
2106
|
-
upstreamReq.on('
|
|
2496
|
+
upstreamReq.on('response', (upRes) => {
|
|
2497
|
+
finishHandshake()
|
|
2498
|
+
if (upgraded || socket.destroyed) { upRes.resume(); return }
|
|
2499
|
+
upRes.resume()
|
|
2500
|
+
writeUpgradeFailure(socket, upRes.statusCode || 502, upRes.statusMessage)
|
|
2501
|
+
socket.destroy()
|
|
2502
|
+
})
|
|
2503
|
+
|
|
2504
|
+
upstreamReq.setTimeout(WS_UPGRADE_TIMEOUT_MS, () => {
|
|
2505
|
+
upstreamReq.destroy(new Error('websocket upgrade timeout'))
|
|
2506
|
+
})
|
|
2507
|
+
handshakeTimer = setTimeout(() => {
|
|
2508
|
+
upstreamReq.destroy(new Error('websocket upgrade timeout'))
|
|
2509
|
+
}, WS_UPGRADE_TIMEOUT_MS)
|
|
2510
|
+
handshakeTimer.unref?.()
|
|
2511
|
+
|
|
2512
|
+
upstreamReq.on('error', (err) => {
|
|
2513
|
+
finishHandshake()
|
|
2107
2514
|
if (!socket.destroyed) {
|
|
2108
|
-
socket
|
|
2515
|
+
writeUpgradeFailure(socket, 502, err?.message || 'Bad Gateway')
|
|
2109
2516
|
socket.destroy()
|
|
2110
2517
|
}
|
|
2111
2518
|
})
|