dsh-remote-plugin 0.6.8 → 0.6.10-rc.1
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 +497 -89
- package/index.mjs +12 -2
- package/package.json +1 -1
- package/public/announcements.json +8 -0
- 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
|
}
|
|
@@ -749,8 +958,9 @@ function serveStats(req, res, url) {
|
|
|
749
958
|
// ---------- 反馈提交 ----------
|
|
750
959
|
const feedbackThrottle = new Map() // ip -> 上次受理时间戳
|
|
751
960
|
const FEEDBACK_WINDOW_MS = 60 * 1000
|
|
752
|
-
// 反馈收集器: 环境变量可覆盖,
|
|
753
|
-
|
|
961
|
+
// 反馈收集器: 环境变量可覆盖, 默认使用公网 HTTPS 入口。
|
|
962
|
+
// 不能把 Tailscale 地址作为默认值,否则普通公网用户的网关无法转发反馈。
|
|
963
|
+
const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'https://feedback.blankalwaysgoeson.site/submit'
|
|
754
964
|
|
|
755
965
|
function maskIp(ip) {
|
|
756
966
|
if (!ip) return 'unknown'
|
|
@@ -938,9 +1148,9 @@ function serveStatic(req, res, url) {
|
|
|
938
1148
|
|
|
939
1149
|
// ---------- 管理 API ----------
|
|
940
1150
|
function upstreamReachable(cb) {
|
|
941
|
-
const req =
|
|
1151
|
+
const req = UPSTREAM_TRANSPORT.request({
|
|
942
1152
|
hostname: UPSTREAM.hostname,
|
|
943
|
-
port:
|
|
1153
|
+
port: UPSTREAM_PORT,
|
|
944
1154
|
method: 'GET',
|
|
945
1155
|
path: '/health',
|
|
946
1156
|
timeout: 1500
|
|
@@ -1904,9 +2114,10 @@ function proxyApi(req, res, url) {
|
|
|
1904
2114
|
headers.authorization = 'Bearer ' + TOKEN
|
|
1905
2115
|
}
|
|
1906
2116
|
|
|
1907
|
-
|
|
2117
|
+
let responseDone = false
|
|
2118
|
+
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
1908
2119
|
hostname: UPSTREAM.hostname,
|
|
1909
|
-
port:
|
|
2120
|
+
port: UPSTREAM_PORT,
|
|
1910
2121
|
method: req.method,
|
|
1911
2122
|
path: url.pathname + url.search,
|
|
1912
2123
|
headers
|
|
@@ -1915,10 +2126,21 @@ function proxyApi(req, res, url) {
|
|
|
1915
2126
|
delete out['content-length']
|
|
1916
2127
|
cors(res)
|
|
1917
2128
|
res.writeHead(upstreamRes.statusCode || 502, out)
|
|
2129
|
+
upstreamRes.on('error', (err) => {
|
|
2130
|
+
if (responseDone || res.destroyed) return
|
|
2131
|
+
responseDone = true
|
|
2132
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
2133
|
+
res.end(JSON.stringify({ error: 'upstream-response-error', detail: String(err.message || err) }))
|
|
2134
|
+
})
|
|
1918
2135
|
upstreamRes.pipe(res)
|
|
1919
2136
|
})
|
|
1920
2137
|
|
|
2138
|
+
upstreamReq.setTimeout(UPSTREAM_REQUEST_TIMEOUT_MS, () => {
|
|
2139
|
+
upstreamReq.destroy(new Error('upstream request timeout'))
|
|
2140
|
+
})
|
|
1921
2141
|
upstreamReq.on('error', (err) => {
|
|
2142
|
+
if (responseDone || res.destroyed) return
|
|
2143
|
+
responseDone = true
|
|
1922
2144
|
cors(res)
|
|
1923
2145
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
|
|
1924
2146
|
res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(err.message || err) }))
|
|
@@ -1926,20 +2148,31 @@ function proxyApi(req, res, url) {
|
|
|
1926
2148
|
|
|
1927
2149
|
req.on('error', () => { upstreamReq.destroy() })
|
|
1928
2150
|
req.on('aborted', () => { upstreamReq.destroy() })
|
|
2151
|
+
res.on('close', () => {
|
|
2152
|
+
if (!res.writableEnded) upstreamReq.destroy()
|
|
2153
|
+
})
|
|
1929
2154
|
req.pipe(upstreamReq)
|
|
1930
2155
|
}
|
|
1931
2156
|
|
|
1932
2157
|
// ---------- 其它 ----------
|
|
1933
2158
|
async function serveHealth(res) {
|
|
1934
2159
|
let upstreamOk = false
|
|
2160
|
+
let upstreamReachable = false
|
|
2161
|
+
let upstreamStatus = 0
|
|
2162
|
+
let upstreamError = ''
|
|
2163
|
+
let timer = null
|
|
1935
2164
|
try {
|
|
1936
2165
|
const ctrl = new AbortController()
|
|
1937
|
-
|
|
1938
|
-
const
|
|
1939
|
-
|
|
2166
|
+
timer = setTimeout(() => ctrl.abort(), 5000)
|
|
2167
|
+
const probeUrl = new URL(DSH_HEALTH_PATH, UPSTREAM).toString()
|
|
2168
|
+
const probe = await fetch(probeUrl, { signal: ctrl.signal, cache: 'no-store' })
|
|
2169
|
+
upstreamReachable = true
|
|
2170
|
+
upstreamStatus = probe.status
|
|
1940
2171
|
upstreamOk = probe.ok
|
|
1941
|
-
} catch {
|
|
1942
|
-
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
upstreamError = String(err?.message || err || '')
|
|
2174
|
+
} finally {
|
|
2175
|
+
if (timer) clearTimeout(timer)
|
|
1943
2176
|
}
|
|
1944
2177
|
cors(res)
|
|
1945
2178
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1949,8 +2182,13 @@ async function serveHealth(res) {
|
|
|
1949
2182
|
version: VERSION,
|
|
1950
2183
|
pid: process.pid,
|
|
1951
2184
|
upstream: UPSTREAM.origin,
|
|
2185
|
+
upstreamProbe: DSH_HEALTH_PATH,
|
|
1952
2186
|
upstreamOk,
|
|
2187
|
+
upstreamReachable,
|
|
2188
|
+
upstreamStatus,
|
|
2189
|
+
...(upstreamError ? { upstreamError } : {}),
|
|
1953
2190
|
events: eventCollectorState,
|
|
2191
|
+
runtime: runtimeState,
|
|
1954
2192
|
}))
|
|
1955
2193
|
}
|
|
1956
2194
|
|
|
@@ -1972,6 +2210,7 @@ const server = http.createServer((req, res) => {
|
|
|
1972
2210
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
1973
2211
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
1974
2212
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
|
2213
|
+
if (url.pathname === '/api/ws-ticket') return serveWsTicket(req, res, url)
|
|
1975
2214
|
if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
|
|
1976
2215
|
if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
|
|
1977
2216
|
if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
|
|
@@ -1990,38 +2229,200 @@ const server = http.createServer((req, res) => {
|
|
|
1990
2229
|
} catch {}
|
|
1991
2230
|
}
|
|
1992
2231
|
})
|
|
2232
|
+
// 长连接与 VPN 上传需要比 Node 默认值更宽松的请求窗口;WebSocket upgrade
|
|
2233
|
+
// 完成后不受 HTTP requestTimeout 影响,升级握手另由 WS_UPGRADE_TIMEOUT_MS 管理。
|
|
2234
|
+
server.requestTimeout = HTTP_REQUEST_TIMEOUT_MS
|
|
2235
|
+
server.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
|
|
2236
|
+
server.keepAliveTimeout = HTTP_KEEPALIVE_TIMEOUT_MS
|
|
2237
|
+
server.timeout = 0
|
|
1993
2238
|
|
|
1994
2239
|
// 最后一层护栏: 任何未捕获异常只记录不退出(网关单点服务, 不能因单请求竞态离线)
|
|
1995
2240
|
process.on('uncaughtException', (err) => {
|
|
2241
|
+
runtimeState.uncaughtExceptions++
|
|
2242
|
+
runtimeState.lastErrorAt = Date.now()
|
|
2243
|
+
runtimeState.lastError = String(err?.message || err || 'uncaught exception')
|
|
1996
2244
|
try { console.error('[uncaughtException]', err?.stack || String(err)) } catch {}
|
|
1997
2245
|
})
|
|
1998
2246
|
process.on('unhandledRejection', (err) => {
|
|
2247
|
+
runtimeState.unhandledRejections++
|
|
2248
|
+
runtimeState.lastErrorAt = Date.now()
|
|
2249
|
+
runtimeState.lastError = String(err?.message || err || 'unhandled rejection')
|
|
1999
2250
|
try { console.error('[unhandledRejection]', err?.stack || String(err)) } catch {}
|
|
2000
2251
|
})
|
|
2001
2252
|
|
|
2253
|
+
function wsPingFrame(masked) {
|
|
2254
|
+
if (!masked) return Buffer.from([0x89, 0x00])
|
|
2255
|
+
const mask = crypto.randomBytes(4)
|
|
2256
|
+
return Buffer.concat([Buffer.from([0x89, 0x80]), mask])
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/**
|
|
2260
|
+
* 原始 TCP 透传也要维护 WebSocket 控制帧活性:
|
|
2261
|
+
* - 浏览器侧收到网关的未掩码 Ping 后会自动回 Pong;
|
|
2262
|
+
* - DSH 侧作为 WebSocket 服务端会自动回网关的掩码 Ping;
|
|
2263
|
+
* - 业务事件可以长时间静默, 不能再把“无业务数据”当作死连接。
|
|
2264
|
+
*/
|
|
2265
|
+
function startWsHeartbeat(clientSocket, upstreamSocket, destroyBoth) {
|
|
2266
|
+
let clientActivity = Date.now()
|
|
2267
|
+
let upstreamActivity = Date.now()
|
|
2268
|
+
let lastClientPing = 0
|
|
2269
|
+
let lastUpstreamPing = 0
|
|
2270
|
+
let timer = null
|
|
2271
|
+
|
|
2272
|
+
const touchClient = () => { clientActivity = Date.now() }
|
|
2273
|
+
const touchUpstream = () => { upstreamActivity = Date.now() }
|
|
2274
|
+
clientSocket.on('data', touchClient)
|
|
2275
|
+
upstreamSocket.on('data', touchUpstream)
|
|
2276
|
+
|
|
2277
|
+
const intervalMs = WS_PING_MS > 0
|
|
2278
|
+
? Math.max(100, Math.min(Math.round(WS_PING_MS / 4), 5000))
|
|
2279
|
+
: WS_IDLE_MS > 0 ? Math.max(1000, Math.min(Math.round(WS_IDLE_MS / 4), 5000)) : 0
|
|
2280
|
+
if (intervalMs > 0) {
|
|
2281
|
+
timer = setInterval(() => {
|
|
2282
|
+
const now = Date.now()
|
|
2283
|
+
if (WS_PING_MS > 0) {
|
|
2284
|
+
if (now - clientActivity > WS_PONG_TIMEOUT_MS || now - upstreamActivity > WS_PONG_TIMEOUT_MS) {
|
|
2285
|
+
destroyBoth()
|
|
2286
|
+
return
|
|
2287
|
+
}
|
|
2288
|
+
if (now - lastClientPing >= WS_PING_MS && !clientSocket.destroyed) {
|
|
2289
|
+
clientSocket.write(wsPingFrame(false))
|
|
2290
|
+
lastClientPing = now
|
|
2291
|
+
}
|
|
2292
|
+
if (now - lastUpstreamPing >= WS_PING_MS && !upstreamSocket.destroyed) {
|
|
2293
|
+
upstreamSocket.write(wsPingFrame(true))
|
|
2294
|
+
lastUpstreamPing = now
|
|
2295
|
+
}
|
|
2296
|
+
} else if ((now - clientActivity > WS_IDLE_MS) || (now - upstreamActivity > WS_IDLE_MS)) {
|
|
2297
|
+
destroyBoth()
|
|
2298
|
+
}
|
|
2299
|
+
}, intervalMs)
|
|
2300
|
+
timer.unref?.()
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
return () => {
|
|
2304
|
+
if (timer) clearInterval(timer)
|
|
2305
|
+
timer = null
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
function startWsClientHeartbeat(socket, destroy) {
|
|
2310
|
+
let activity = Date.now()
|
|
2311
|
+
let lastPing = 0
|
|
2312
|
+
let timer = null
|
|
2313
|
+
socket.on('data', () => { activity = Date.now() })
|
|
2314
|
+
const intervalMs = WS_PING_MS > 0
|
|
2315
|
+
? Math.max(100, Math.min(Math.round(WS_PING_MS / 4), 5000))
|
|
2316
|
+
: WS_IDLE_MS > 0 ? Math.max(1000, Math.min(Math.round(WS_IDLE_MS / 4), 5000)) : 0
|
|
2317
|
+
if (intervalMs > 0) {
|
|
2318
|
+
timer = setInterval(() => {
|
|
2319
|
+
const now = Date.now()
|
|
2320
|
+
if (WS_PING_MS > 0) {
|
|
2321
|
+
if (now - activity > WS_PONG_TIMEOUT_MS) {
|
|
2322
|
+
destroy()
|
|
2323
|
+
return
|
|
2324
|
+
}
|
|
2325
|
+
if (now - lastPing >= WS_PING_MS && !socket.destroyed) {
|
|
2326
|
+
socket.write(wsPingFrame(false))
|
|
2327
|
+
lastPing = now
|
|
2328
|
+
}
|
|
2329
|
+
} else if (now - activity > WS_IDLE_MS) {
|
|
2330
|
+
destroy()
|
|
2331
|
+
}
|
|
2332
|
+
}, intervalMs)
|
|
2333
|
+
timer.unref?.()
|
|
2334
|
+
}
|
|
2335
|
+
return () => {
|
|
2336
|
+
if (timer) clearInterval(timer)
|
|
2337
|
+
timer = null
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function acceptCollectorClient(req, socket, head, kind, device) {
|
|
2342
|
+
const key = req.headers['sec-websocket-key']
|
|
2343
|
+
if (!key) {
|
|
2344
|
+
if (device) {
|
|
2345
|
+
device.sockets.delete(socket)
|
|
2346
|
+
const count = Math.max(0, (device.channelCounts[kind] || 1) - 1)
|
|
2347
|
+
device.channelCounts[kind] = count
|
|
2348
|
+
device.channels[kind] = count > 0
|
|
2349
|
+
}
|
|
2350
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
|
|
2351
|
+
return
|
|
2352
|
+
}
|
|
2353
|
+
socket.write(
|
|
2354
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
2355
|
+
'Upgrade: websocket\r\n' +
|
|
2356
|
+
'Connection: Upgrade\r\n' +
|
|
2357
|
+
`Sec-WebSocket-Accept: ${wsAccept(key)}\r\n\r\n`
|
|
2358
|
+
)
|
|
2359
|
+
if (head?.length) socket.unshift(head)
|
|
2360
|
+
socket.setNoDelay(true)
|
|
2361
|
+
collectorClients[kind].add(socket)
|
|
2362
|
+
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
2363
|
+
for (const raw of collectorReplay[kind].values()) {
|
|
2364
|
+
if (socket.destroyed || !socket.writable) break
|
|
2365
|
+
try { socket.write(encodeWsText(raw)) } catch { break }
|
|
2366
|
+
}
|
|
2367
|
+
const stopHeartbeat = startWsClientHeartbeat(socket, () => socket.destroy())
|
|
2368
|
+
const release = () => {
|
|
2369
|
+
stopHeartbeat()
|
|
2370
|
+
collectorClients[kind].delete(socket)
|
|
2371
|
+
eventCollectorState[kind].clients = collectorClients[kind].size
|
|
2372
|
+
if (device) {
|
|
2373
|
+
device.sockets.delete(socket)
|
|
2374
|
+
const count = Math.max(0, (device.channelCounts[kind] || 1) - 1)
|
|
2375
|
+
device.channelCounts[kind] = count
|
|
2376
|
+
device.channels[kind] = count > 0
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
socket.once('close', release)
|
|
2380
|
+
socket.once('error', () => { try { socket.destroy() } catch {} })
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function writeUpgradeFailure(socket, statusCode, statusMessage) {
|
|
2384
|
+
if (socket.destroyed || !socket.writable) return
|
|
2385
|
+
const text = `upstream websocket upgrade failed: ${statusCode} ${statusMessage || ''}`.trim()
|
|
2386
|
+
const body = Buffer.from(text + '\n')
|
|
2387
|
+
const headers =
|
|
2388
|
+
`HTTP/1.1 ${statusCode} ${statusMessage || 'Bad Gateway'}\r\n` +
|
|
2389
|
+
'Connection: close\r\n' +
|
|
2390
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
2391
|
+
`Content-Length: ${body.length}\r\n\r\n`
|
|
2392
|
+
socket.end(Buffer.concat([Buffer.from(headers), body]))
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2002
2395
|
server.on('upgrade', (req, socket, head) => {
|
|
2003
2396
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
2004
2397
|
if (!url.pathname.startsWith('/api/')) {
|
|
2005
2398
|
socket.destroy()
|
|
2006
2399
|
return
|
|
2007
2400
|
}
|
|
2008
|
-
const ok = authorized(req, url)
|
|
2401
|
+
const ok = authorized(req, url, { consumeTicket: true })
|
|
2009
2402
|
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)
|
|
2403
|
+
const clientId = url.searchParams.get('clientId') || ''
|
|
2404
|
+
const deviceExtra = ok && channel ? { channel, clientId } : { failedAuth: !ok, clientId }
|
|
2405
|
+
const d = touchDevice(req, deviceExtra)
|
|
2018
2406
|
if (!ok) {
|
|
2019
2407
|
authFailures++
|
|
2020
2408
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
|
2021
|
-
|
|
2409
|
+
try { socket.destroy() } catch {}
|
|
2022
2410
|
return
|
|
2023
2411
|
}
|
|
2024
2412
|
|
|
2413
|
+
if (channel) {
|
|
2414
|
+
d.sockets.add(socket)
|
|
2415
|
+
acceptCollectorClient(req, socket, head, channel, d)
|
|
2416
|
+
return
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
if (d) d.sockets.add(socket)
|
|
2420
|
+
const release = () => {
|
|
2421
|
+
d.sockets.delete(socket)
|
|
2422
|
+
try { socket.destroy() } catch {}
|
|
2423
|
+
}
|
|
2424
|
+
socket.once('close', release)
|
|
2425
|
+
|
|
2025
2426
|
const headers = {}
|
|
2026
2427
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
2027
2428
|
if (v === undefined) continue
|
|
@@ -2041,15 +2442,27 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
2041
2442
|
if (req.headers['sec-websocket-protocol']) headers['sec-websocket-protocol'] = req.headers['sec-websocket-protocol']
|
|
2042
2443
|
if (req.headers['sec-websocket-extensions']) headers['sec-websocket-extensions'] = req.headers['sec-websocket-extensions']
|
|
2043
2444
|
|
|
2044
|
-
|
|
2445
|
+
let upgraded = false
|
|
2446
|
+
let handshakeTimer = null
|
|
2447
|
+
const finishHandshake = () => {
|
|
2448
|
+
if (handshakeTimer) clearTimeout(handshakeTimer)
|
|
2449
|
+
handshakeTimer = null
|
|
2450
|
+
}
|
|
2451
|
+
const upstreamReq = UPSTREAM_TRANSPORT.request({
|
|
2045
2452
|
hostname: UPSTREAM.hostname,
|
|
2046
|
-
port:
|
|
2453
|
+
port: UPSTREAM_PORT,
|
|
2047
2454
|
method: req.method,
|
|
2048
2455
|
path: url.pathname + url.search,
|
|
2049
2456
|
headers
|
|
2050
2457
|
})
|
|
2458
|
+
socket.once('close', () => {
|
|
2459
|
+
finishHandshake()
|
|
2460
|
+
if (!upgraded) upstreamReq.destroy()
|
|
2461
|
+
})
|
|
2051
2462
|
|
|
2052
2463
|
upstreamReq.on('upgrade', (upRes, upSocket, upHead) => {
|
|
2464
|
+
upgraded = true
|
|
2465
|
+
finishHandshake()
|
|
2053
2466
|
if (socket.destroyed) { upSocket.destroy(); return }
|
|
2054
2467
|
const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`]
|
|
2055
2468
|
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
@@ -2064,48 +2477,43 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
2064
2477
|
upSocket.setNoDelay(true)
|
|
2065
2478
|
upSocket.pipe(socket)
|
|
2066
2479
|
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
2480
|
const destroyBoth = () => {
|
|
2077
|
-
|
|
2481
|
+
heartbeatStop()
|
|
2078
2482
|
upSocket.destroy()
|
|
2079
2483
|
socket.destroy()
|
|
2080
2484
|
}
|
|
2081
2485
|
const close = () => {
|
|
2082
|
-
|
|
2486
|
+
heartbeatStop()
|
|
2083
2487
|
upSocket.destroy()
|
|
2084
2488
|
socket.destroy()
|
|
2085
2489
|
}
|
|
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()
|
|
2490
|
+
const heartbeatStop = startWsHeartbeat(socket, upSocket, destroyBoth)
|
|
2100
2491
|
upSocket.on('error', close)
|
|
2101
2492
|
socket.on('error', close)
|
|
2102
|
-
upSocket.on('close', () => {
|
|
2103
|
-
socket.on('close', () => {
|
|
2493
|
+
upSocket.on('close', () => { heartbeatStop(); if (!socket.destroyed) socket.end() })
|
|
2494
|
+
socket.on('close', () => { heartbeatStop(); if (!upSocket.destroyed) upSocket.end() })
|
|
2104
2495
|
})
|
|
2105
2496
|
|
|
2106
|
-
upstreamReq.on('
|
|
2497
|
+
upstreamReq.on('response', (upRes) => {
|
|
2498
|
+
finishHandshake()
|
|
2499
|
+
if (upgraded || socket.destroyed) { upRes.resume(); return }
|
|
2500
|
+
upRes.resume()
|
|
2501
|
+
writeUpgradeFailure(socket, upRes.statusCode || 502, upRes.statusMessage)
|
|
2502
|
+
socket.destroy()
|
|
2503
|
+
})
|
|
2504
|
+
|
|
2505
|
+
upstreamReq.setTimeout(WS_UPGRADE_TIMEOUT_MS, () => {
|
|
2506
|
+
upstreamReq.destroy(new Error('websocket upgrade timeout'))
|
|
2507
|
+
})
|
|
2508
|
+
handshakeTimer = setTimeout(() => {
|
|
2509
|
+
upstreamReq.destroy(new Error('websocket upgrade timeout'))
|
|
2510
|
+
}, WS_UPGRADE_TIMEOUT_MS)
|
|
2511
|
+
handshakeTimer.unref?.()
|
|
2512
|
+
|
|
2513
|
+
upstreamReq.on('error', (err) => {
|
|
2514
|
+
finishHandshake()
|
|
2107
2515
|
if (!socket.destroyed) {
|
|
2108
|
-
socket
|
|
2516
|
+
writeUpgradeFailure(socket, 502, err?.message || 'Bad Gateway')
|
|
2109
2517
|
socket.destroy()
|
|
2110
2518
|
}
|
|
2111
2519
|
})
|