dsh-remote-plugin 0.6.7 → 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/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
- const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
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
- return tokenOf(req, url) === TOKEN
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(ip)
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(ip, d)
294
+ devices.set(deviceKey, d)
239
295
  }
240
296
  d.lastSeen = Date.now()
241
297
  d.requests++
242
- if (extra.channel) d.channels[extra.channel] = true
243
- if (extra.closeChannel) d.channels[extra.closeChannel] = false
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 d = devices.get(ip)
271
- if (!d) return 0
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 sock of d.sockets) {
274
- try { sock.destroy() } catch {}
275
- n++
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
- function cors(res) {
418
- res.setHeader('access-control-allow-origin', '*')
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
- // 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
528
- // 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
529
- const EVENT_BUFFER_MAX = 300
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 pushEvent(kind, full) {
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
- const url = `ws://${UPSTREAM.hostname}:${UPSTREAM.port}/api/events.${kind}?client=web`
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
- retryTimer = setTimeout(connect, 3000)
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
- if (!stopped) retryTimer = setTimeout(connect, 3000)
843
+ schedule()
641
844
  }
642
845
  ws.onerror = (err) => {
643
846
  if (state) state.lastError = String(err?.message || 'websocket error')
644
- try { ws.close() } catch {}
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
- statsStore.ingestEvent(sessionId, event, payload.fallbackModel).then((out) => {
734
- if (out.gap) scanStatsOnce(3000)
735
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
736
- res.end(JSON.stringify({ ok: true, ...out }))
737
- }).catch((err) => {
738
- res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
739
- res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
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 = http.request({
1150
+ const req = UPSTREAM_TRANSPORT.request({
942
1151
  hostname: UPSTREAM.hostname,
943
- port: UPSTREAM.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
- const upstreamReq = http.request({
2116
+ let responseDone = false
2117
+ const upstreamReq = UPSTREAM_TRANSPORT.request({
1908
2118
  hostname: UPSTREAM.hostname,
1909
- port: UPSTREAM.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
- const timer = setTimeout(() => ctrl.abort(), 2000)
1938
- const probe = await fetch(UPSTREAM.origin + '/healthz', { signal: ctrl.signal, cache: 'no-store' })
1939
- clearTimeout(timer)
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
- upstreamOk = false
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 d = touchDevice(req, ok && channel ? { channel } : { failedAuth: !ok })
2011
- if (d) d.sockets.add(socket)
2012
- const release = () => {
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
- release()
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
- const upstreamReq = http.request({
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: UPSTREAM.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
- clearIdle()
2480
+ heartbeatStop()
2078
2481
  upSocket.destroy()
2079
2482
  socket.destroy()
2080
2483
  }
2081
2484
  const close = () => {
2082
- clearIdle()
2485
+ heartbeatStop()
2083
2486
  upSocket.destroy()
2084
2487
  socket.destroy()
2085
2488
  }
2086
- const touchUp = () => {
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', () => { clearIdle(); if (!socket.destroyed) socket.end() })
2103
- socket.on('close', () => { clearIdle(); if (!upSocket.destroyed) upSocket.end() })
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('error', () => {
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.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
2515
+ writeUpgradeFailure(socket, 502, err?.message || 'Bad Gateway')
2109
2516
  socket.destroy()
2110
2517
  }
2111
2518
  })