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/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
  }
@@ -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
- // 反馈收集器: 环境变量可覆盖, 默认 Tailscale 内网地址
753
- const FEEDBACK_URL = process.env.DSH_REMOTE_FEEDBACK_URL || 'http://100.84.128.29/submit'
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 = http.request({
1151
+ const req = UPSTREAM_TRANSPORT.request({
942
1152
  hostname: UPSTREAM.hostname,
943
- port: UPSTREAM.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
- const upstreamReq = http.request({
2117
+ let responseDone = false
2118
+ const upstreamReq = UPSTREAM_TRANSPORT.request({
1908
2119
  hostname: UPSTREAM.hostname,
1909
- port: UPSTREAM.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
- const timer = setTimeout(() => ctrl.abort(), 2000)
1938
- const probe = await fetch(UPSTREAM.origin + '/healthz', { signal: ctrl.signal, cache: 'no-store' })
1939
- clearTimeout(timer)
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
- upstreamOk = false
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 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)
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
- release()
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
- const upstreamReq = http.request({
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: UPSTREAM.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
- clearIdle()
2481
+ heartbeatStop()
2078
2482
  upSocket.destroy()
2079
2483
  socket.destroy()
2080
2484
  }
2081
2485
  const close = () => {
2082
- clearIdle()
2486
+ heartbeatStop()
2083
2487
  upSocket.destroy()
2084
2488
  socket.destroy()
2085
2489
  }
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()
2490
+ const heartbeatStop = startWsHeartbeat(socket, upSocket, destroyBoth)
2100
2491
  upSocket.on('error', close)
2101
2492
  socket.on('error', close)
2102
- upSocket.on('close', () => { clearIdle(); if (!socket.destroyed) socket.end() })
2103
- socket.on('close', () => { clearIdle(); if (!upSocket.destroyed) upSocket.end() })
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('error', () => {
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.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
2516
+ writeUpgradeFailure(socket, 502, err?.message || 'Bad Gateway')
2109
2517
  socket.destroy()
2110
2518
  }
2111
2519
  })