dsh-remote-plugin 0.4.4

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 ADDED
@@ -0,0 +1,675 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DSH Remote 网关 —— 零依赖 Node 服务
4
+ *
5
+ * 作用:
6
+ * 1. 静态托管 mobile web 控制台 (public/) 与管理页 (/admin)
7
+ * 2. 把 /api/* 请求(HTTP + WebSocket)代理到本机 DSH (127.0.0.1:3080)
8
+ * 3. Bearer Token 认证 + 已连接设备/请求状态监控
9
+ *
10
+ * 用法:
11
+ * node gateway.js # 默认 0.0.0.0:8787
12
+ * PORT=9000 TOKEN=xxx node gateway.js
13
+ * DSH_UPSTREAM=http://127.0.0.1:3080 node gateway.js
14
+ *
15
+ * 环境变量:
16
+ * PORT 监听端口, 默认 8787
17
+ * HOST 监听地址, 默认 0.0.0.0
18
+ * DSH_UPSTREAM DSH web 服务地址, 默认 http://127.0.0.1:3080
19
+ * TOKEN 访问令牌; 不设置则读 TOKEN_FILE, 仍没有则自动生成
20
+ * TOKEN_FILE 令牌文件, 默认 ~/.dsh-remote/token
21
+ */
22
+ 'use strict'
23
+
24
+ const http = require('node:http')
25
+ const https = require('node:https')
26
+ const fs = require('node:fs')
27
+ const path = require('node:path')
28
+ const os = require('node:os')
29
+ const crypto = require('node:crypto')
30
+
31
+ const ROOT = __dirname
32
+ const PUBLIC_DIR = path.join(ROOT, 'public')
33
+ const PORT = Number(process.env.PORT) || 8787
34
+ const HOST = process.env.HOST || '0.0.0.0'
35
+ const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
36
+ const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
37
+ const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
38
+ const STARTED_AT = Date.now()
39
+
40
+ // 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
41
+ const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
42
+ 'https://api.github.com/repos/Blank-not-black/dsh-Remote/releases/latest'
43
+ const UPDATE_INTERVAL_MS = Number(process.env.UPDATE_INTERVAL_MS) || 6 * 3600 * 1000
44
+ const latestState = { version: null, url: null, tag: null, checkedAt: 0, error: '' }
45
+
46
+ function gatewayVersion() {
47
+ try {
48
+ const v = JSON.parse(fs.readFileSync(path.join(PUBLIC_DIR, 'version.json'), 'utf8'))
49
+ return v.version || '0.0.0'
50
+ } catch {
51
+ return '0.0.0'
52
+ }
53
+ }
54
+ const VERSION = gatewayVersion()
55
+
56
+ const MIME = {
57
+ '.html': 'text/html; charset=utf-8',
58
+ '.js': 'text/javascript; charset=utf-8',
59
+ '.mjs': 'text/javascript; charset=utf-8',
60
+ '.css': 'text/css; charset=utf-8',
61
+ '.json': 'application/json; charset=utf-8',
62
+ '.svg': 'image/svg+xml',
63
+ '.png': 'image/png',
64
+ '.ico': 'image/x-icon',
65
+ '.txt': 'text/plain; charset=utf-8',
66
+ '.md': 'text/markdown; charset=utf-8',
67
+ '.woff2': 'font/woff2',
68
+ '.webmanifest': 'application/manifest+json; charset=utf-8',
69
+ '.apk': 'application/vnd.android.package-archive'
70
+ }
71
+
72
+ // ---------- token ----------
73
+ function loadToken() {
74
+ if (process.env.TOKEN) return process.env.TOKEN
75
+ try {
76
+ const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim()
77
+ if (t) return t
78
+ } catch {}
79
+ const token = crypto.randomBytes(24).toString('base64url')
80
+ try {
81
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
82
+ fs.writeFileSync(TOKEN_FILE, token + '\n', { mode: 0o600 })
83
+ } catch {}
84
+ return token
85
+ }
86
+
87
+ const TOKEN = loadToken()
88
+
89
+ function tokenOf(req, url) {
90
+ const auth = req.headers.authorization || ''
91
+ const m = /^Bearer\s+(.+)$/i.exec(auth)
92
+ if (m) return m[1]
93
+ return url.searchParams.get('token')
94
+ }
95
+
96
+ function authorized(req, url) {
97
+ return tokenOf(req, url) === TOKEN
98
+ }
99
+
100
+ // ---------- 设备监控 ----------
101
+ const devices = new Map() // ip -> device
102
+ let totalRequests = 0
103
+ let authFailures = 0
104
+
105
+ function loadNotes() {
106
+ try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')) } catch { return {} }
107
+ }
108
+ function saveNotes(notes) {
109
+ try {
110
+ fs.mkdirSync(path.dirname(NOTES_FILE), { recursive: true })
111
+ fs.writeFileSync(NOTES_FILE, JSON.stringify(notes, null, 2))
112
+ } catch {}
113
+ }
114
+ const deviceNotes = loadNotes()
115
+
116
+ function ipOf(req) {
117
+ return String(req.socket?.remoteAddress || '').replace(/^::ffff:/, '') || 'unknown'
118
+ }
119
+
120
+ function kindOf(req) {
121
+ const marked = req.headers['x-dsh-remote-client']
122
+ if (marked === 'app') return 'app'
123
+ if (marked === 'web') return 'web'
124
+ if (marked === 'admin') return 'admin'
125
+ const ua = String(req.headers['user-agent'] || '')
126
+ if (/DSHRemoteApp/i.test(ua)) return 'app'
127
+ return 'browser'
128
+ }
129
+
130
+ function touchDevice(req, extra = {}) {
131
+ const ip = ipOf(req)
132
+ totalRequests++
133
+ let d = devices.get(ip)
134
+ if (!d) {
135
+ d = {
136
+ ip, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
137
+ requests: 0, authFailures: 0, channels: {}, sockets: new Set()
138
+ }
139
+ devices.set(ip, d)
140
+ }
141
+ d.lastSeen = Date.now()
142
+ d.requests++
143
+ if (extra.channel) d.channels[extra.channel] = true
144
+ if (extra.closeChannel) d.channels[extra.closeChannel] = false
145
+ if (extra.failedAuth) d.authFailures++
146
+ const marked = req.headers['x-dsh-remote-client']
147
+ if (marked) d.kind = marked
148
+ const ua = String(req.headers['user-agent'] || '')
149
+ if (ua && ua.length > d.ua.length) d.ua = ua
150
+ return d
151
+ }
152
+
153
+ function deviceViews() {
154
+ return [...devices.values()]
155
+ .map(d => ({
156
+ ip: d.ip,
157
+ note: deviceNotes[d.ip] || '',
158
+ kind: d.kind,
159
+ ua: d.ua,
160
+ firstSeen: d.firstSeen,
161
+ lastSeen: d.lastSeen,
162
+ requests: d.requests,
163
+ authFailures: d.authFailures,
164
+ channels: { ...d.channels },
165
+ online: Date.now() - d.lastSeen < 60_000
166
+ }))
167
+ .sort((a, b) => b.lastSeen - a.lastSeen)
168
+ }
169
+
170
+ function kickDevice(ip) {
171
+ const d = devices.get(ip)
172
+ if (!d) return 0
173
+ let n = 0
174
+ for (const sock of d.sockets) {
175
+ try { sock.destroy() } catch {}
176
+ n++
177
+ }
178
+ d.sockets.clear()
179
+ d.channels = {}
180
+ return n
181
+ }
182
+
183
+ // ---------- GitHub/镜像 更新检查 ----------
184
+ function cmpVersion(a, b) {
185
+ const pa = String(a || '').split('.').map(Number)
186
+ const pb = String(b || '').split('.').map(Number)
187
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
188
+ const d = (pa[i] || 0) - (pb[i] || 0)
189
+ if (d) return d
190
+ }
191
+ return 0
192
+ }
193
+
194
+ function httpGetJson(url, cb) {
195
+ let u
196
+ try { u = new URL(url) } catch (e) { cb(new Error('更新源地址无效')); return }
197
+ const isHttps = u.protocol === 'https:'
198
+ const lib = isHttps ? https : http
199
+ const proxyEnv = process.env.UPDATE_PROXY ||
200
+ (isHttps ? process.env.HTTPS_PROXY : process.env.HTTP_PROXY) || ''
201
+ const done = (err, value) => { if (settled) return; settled = true; cb(err, value) }
202
+ let settled = false
203
+ const timer = setTimeout(() => done(new Error('检查超时')), 6000)
204
+
205
+ const request = (agent) => {
206
+ const req = lib.request({
207
+ hostname: u.hostname,
208
+ port: u.port || (isHttps ? 443 : 80),
209
+ method: 'GET',
210
+ path: u.pathname + u.search,
211
+ headers: {
212
+ 'user-agent': 'dsh-remote-gateway/' + VERSION,
213
+ accept: 'application/json'
214
+ },
215
+ agent
216
+ }, (res) => {
217
+ let body = ''
218
+ res.on('data', c => { body += c; if (body.length > 512 * 1024) res.destroy() })
219
+ res.on('end', () => {
220
+ if (res.statusCode >= 400) return done(new Error('HTTP ' + res.statusCode))
221
+ try { done(null, JSON.parse(body)) } catch (e) { done(e) }
222
+ })
223
+ res.on('error', (e) => done(e))
224
+ })
225
+ req.on('error', (e) => done(e))
226
+ req.end()
227
+ }
228
+
229
+ if (proxyEnv) {
230
+ try {
231
+ const p = new URL(proxyEnv)
232
+ if (isHttps) {
233
+ // https 经 http CONNECT 隧道
234
+ const connect = http.request({
235
+ hostname: p.hostname,
236
+ port: p.port || 80,
237
+ method: 'CONNECT',
238
+ path: `${u.hostname}:${u.port || 443}`
239
+ })
240
+ connect.setTimeout(5000, () => { connect.destroy(); done(new Error('代理超时')) })
241
+ connect.on('connect', (res, socket) => {
242
+ if (res.statusCode !== 200) { socket.destroy(); return done(new Error('代理拒绝 ' + res.statusCode)) }
243
+ const agent = new https.Agent({ keepAlive: true, createConnection: () => socket })
244
+ request(agent)
245
+ })
246
+ connect.on('error', (e) => done(e))
247
+ connect.end()
248
+ return
249
+ }
250
+ // http 代理: 完整 URL + 主机头
251
+ const req = http.request({
252
+ hostname: p.hostname,
253
+ port: p.port || 80,
254
+ method: 'GET',
255
+ path: url,
256
+ headers: { host: u.host, 'user-agent': 'dsh-remote-gateway/' + VERSION, accept: 'application/json' }
257
+ }, (res) => {
258
+ let body = ''
259
+ res.on('data', c => { body += c; if (body.length > 512 * 1024) res.destroy() })
260
+ res.on('end', () => {
261
+ if (res.statusCode >= 400) return done(new Error('HTTP ' + res.statusCode))
262
+ try { done(null, JSON.parse(body)) } catch (e) { done(e) }
263
+ })
264
+ res.on('error', (e) => done(e))
265
+ })
266
+ req.on('error', (e) => done(e))
267
+ req.end()
268
+ return
269
+ } catch (e) {
270
+ done(e)
271
+ return
272
+ }
273
+ }
274
+ request(undefined)
275
+ }
276
+
277
+ function checkForUpdates(verbose) {
278
+ httpGetJson(UPDATE_CHECK_URL, (err, data) => {
279
+ latestState.checkedAt = Date.now()
280
+ if (err) {
281
+ latestState.error = err.message || String(err)
282
+ if (verbose) console.log(' 检查更新失败(可忽略): ' + latestState.error)
283
+ return
284
+ }
285
+ latestState.error = ''
286
+ const ver = String(data?.tag_name || data?.name || '').replace(/^v/i, '')
287
+ latestState.version = ver || null
288
+ latestState.tag = data?.tag_name || null
289
+ latestState.url = data?.html_url || null
290
+ if (latestState.version && cmpVersion(latestState.version, VERSION) > 0) {
291
+ console.log(` ⚡ 发现新版本 v${latestState.version} (当前 v${VERSION})`)
292
+ console.log(' 下载: ' + (latestState.url || UPDATE_CHECK_URL))
293
+ } else if (verbose) {
294
+ console.log(` 已是最新版本 v${VERSION}`)
295
+ }
296
+ })
297
+ }
298
+
299
+ // ---------- CORS ----------
300
+ function cors(res) {
301
+ res.setHeader('access-control-allow-origin', '*')
302
+ res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client')
303
+ res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
304
+ }
305
+
306
+ // ---------- 静态文件 ----------
307
+ function serveStatic(req, res, url) {
308
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
309
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
310
+ res.end('405 Method Not Allowed')
311
+ return
312
+ }
313
+ let pathname
314
+ try {
315
+ pathname = decodeURIComponent(url.pathname)
316
+ } catch {
317
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
318
+ res.end('400 Bad Request')
319
+ return
320
+ }
321
+ if (pathname === '/') pathname = '/index.html'
322
+ if (pathname === '/admin') pathname = '/admin.html'
323
+ const apkOverride = pathname === '/dsh-remote.apk'
324
+ const baseDir = apkOverride ? path.join(ROOT, 'apk') : PUBLIC_DIR
325
+ const filePath = path.normalize(path.join(baseDir, pathname))
326
+ if (filePath !== baseDir && !filePath.startsWith(baseDir + path.sep)) {
327
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
328
+ res.end('403 Forbidden')
329
+ return
330
+ }
331
+ fs.stat(filePath, (err, st) => {
332
+ if (err || !st.isFile()) {
333
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
334
+ res.end('404 Not Found')
335
+ return
336
+ }
337
+ const ext = path.extname(filePath).toLowerCase()
338
+ cors(res)
339
+ res.writeHead(200, {
340
+ 'content-type': MIME[ext] || 'application/octet-stream',
341
+ 'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
342
+ 'content-length': st.size
343
+ })
344
+ if (req.method === 'HEAD') res.end()
345
+ else fs.createReadStream(filePath).pipe(res)
346
+ })
347
+ }
348
+
349
+ // ---------- 管理 API ----------
350
+ function upstreamReachable(cb) {
351
+ const req = http.request({
352
+ hostname: UPSTREAM.hostname,
353
+ port: UPSTREAM.port,
354
+ method: 'GET',
355
+ path: '/health',
356
+ timeout: 1500
357
+ }, (res) => {
358
+ res.resume()
359
+ cb(true)
360
+ })
361
+ req.on('error', () => cb(false))
362
+ req.on('timeout', () => { req.destroy(); cb(false) })
363
+ req.end()
364
+ }
365
+
366
+ function serveAdminApi(req, res, url) {
367
+ const sub = url.pathname.slice('/admin/api'.length) || '/'
368
+ if (sub === '/state' && req.method === 'GET') {
369
+ if (!authorized(req, url)) {
370
+ authFailures++
371
+ touchDevice(req, { failedAuth: true })
372
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
373
+ res.end(JSON.stringify({ error: 'unauthorized' }))
374
+ return
375
+ }
376
+ upstreamReachable((reachable) => {
377
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
378
+ res.end(JSON.stringify({
379
+ ok: true,
380
+ version: VERSION,
381
+ pid: process.pid,
382
+ hostname: os.hostname(),
383
+ lanIPs: lanAddresses(),
384
+ startedAt: STARTED_AT,
385
+ uptimeSec: Math.round((Date.now() - STARTED_AT) / 1000),
386
+ host: HOST,
387
+ port: PORT,
388
+ upstream: { url: UPSTREAM.origin, reachable },
389
+ latest: {
390
+ version: latestState.version,
391
+ tag: latestState.tag,
392
+ url: latestState.url,
393
+ checkedAt: latestState.checkedAt,
394
+ error: latestState.error,
395
+ newer: !!(latestState.version && cmpVersion(latestState.version, VERSION) > 0)
396
+ },
397
+ tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
398
+ tokenLength: TOKEN.length,
399
+ totalRequests,
400
+ authFailures,
401
+ deviceCount: devices.size,
402
+ onlineCount: [...devices.values()].filter(d => Date.now() - d.lastSeen < 60_000).length,
403
+ devices: deviceViews()
404
+ }))
405
+ })
406
+ return
407
+ }
408
+ if (sub === '/shutdown' && req.method === 'POST') {
409
+ if (!authorized(req, url)) {
410
+ authFailures++
411
+ touchDevice(req, { failedAuth: true })
412
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
413
+ res.end(JSON.stringify({ error: 'unauthorized' }))
414
+ return
415
+ }
416
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
417
+ res.end(JSON.stringify({ ok: true, bye: true }))
418
+ // 给响应留出发送时间, 然后退出; 由插件/系统按需再拉起
419
+ setTimeout(() => {
420
+ console.log('[shutdown] 收到管理端停止指令, 网关退出')
421
+ process.exit(0)
422
+ }, 150)
423
+ return
424
+ }
425
+ if (sub === '/note' && req.method === 'POST') {
426
+ if (!authorized(req, url)) {
427
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
428
+ res.end(JSON.stringify({ error: 'unauthorized' }))
429
+ return
430
+ }
431
+ let body = ''
432
+ req.on('data', c => { body += c; if (body.length > 4096) req.destroy() })
433
+ req.on('end', () => {
434
+ try {
435
+ const { ip, name } = JSON.parse(body || '{}')
436
+ if (typeof ip !== 'string' || typeof name !== 'string') throw new Error('bad')
437
+ const note = name.trim().slice(0, 40)
438
+ if (note) deviceNotes[ip] = note
439
+ else delete deviceNotes[ip]
440
+ saveNotes(deviceNotes)
441
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
442
+ res.end(JSON.stringify({ ok: true }))
443
+ } catch {
444
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
445
+ res.end(JSON.stringify({ error: 'bad-request' }))
446
+ }
447
+ })
448
+ return
449
+ }
450
+ if (sub === '/kick' && req.method === 'POST') {
451
+ if (!authorized(req, url)) {
452
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
453
+ res.end(JSON.stringify({ error: 'unauthorized' }))
454
+ return
455
+ }
456
+ let body = ''
457
+ req.on('data', c => { body += c; if (body.length > 1024) req.destroy() })
458
+ req.on('end', () => {
459
+ try {
460
+ const ip = JSON.parse(body || '{}').ip
461
+ const n = typeof ip === 'string' ? kickDevice(ip) : 0
462
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
463
+ res.end(JSON.stringify({ kicked: n }))
464
+ } catch {
465
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
466
+ res.end(JSON.stringify({ error: 'bad-request' }))
467
+ }
468
+ })
469
+ return
470
+ }
471
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
472
+ res.end(JSON.stringify({ error: 'not-found' }))
473
+ }
474
+
475
+ // ---------- /api 代理 ----------
476
+ function proxyApi(req, res, url) {
477
+ if (req.method === 'OPTIONS') {
478
+ cors(res)
479
+ res.writeHead(204)
480
+ res.end()
481
+ return
482
+ }
483
+ const ok = authorized(req, url)
484
+ touchDevice(req, ok ? {} : { failedAuth: true })
485
+ if (!ok) {
486
+ authFailures++
487
+ cors(res)
488
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
489
+ res.end(JSON.stringify({ error: 'unauthorized' }))
490
+ return
491
+ }
492
+
493
+ const headers = {}
494
+ for (const [k, v] of Object.entries(req.headers)) {
495
+ if (v === undefined) continue
496
+ const key = k.toLowerCase()
497
+ if (['host', 'authorization', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
498
+ 'proxy-connection', 'accept-encoding', 'origin', 'referer',
499
+ 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
500
+ 'x-dsh-remote-client'].includes(key)) continue
501
+ headers[k] = v
502
+ }
503
+ headers.host = UPSTREAM.host
504
+
505
+ const upstreamReq = http.request({
506
+ hostname: UPSTREAM.hostname,
507
+ port: UPSTREAM.port,
508
+ method: req.method,
509
+ path: url.pathname + url.search,
510
+ headers
511
+ }, (upstreamRes) => {
512
+ const out = { ...upstreamRes.headers }
513
+ delete out['content-length']
514
+ cors(res)
515
+ res.writeHead(upstreamRes.statusCode || 502, out)
516
+ upstreamRes.pipe(res)
517
+ })
518
+
519
+ upstreamReq.on('error', (err) => {
520
+ cors(res)
521
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' })
522
+ res.end(JSON.stringify({ error: 'upstream-unreachable', detail: String(err.message || err) }))
523
+ })
524
+
525
+ req.on('error', () => { upstreamReq.destroy() })
526
+ req.on('aborted', () => { upstreamReq.destroy() })
527
+ req.pipe(upstreamReq)
528
+ }
529
+
530
+ // ---------- 其它 ----------
531
+ function serveHealth(res) {
532
+ cors(res)
533
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
534
+ res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, upstream: UPSTREAM.origin }))
535
+ }
536
+
537
+ function lanAddresses() {
538
+ const out = []
539
+ for (const infos of Object.values(os.networkInterfaces())) {
540
+ for (const info of infos || []) {
541
+ if (info.family === 'IPv4' && !info.internal) out.push(info.address)
542
+ }
543
+ }
544
+ return out
545
+ }
546
+
547
+ const server = http.createServer((req, res) => {
548
+ try {
549
+ const url = new URL(req.url, 'http://dsh-remote.local')
550
+ if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
551
+ if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
552
+ if (url.pathname === '/health') return serveHealth(res)
553
+ touchDevice(req)
554
+ return serveStatic(req, res, url)
555
+ } catch (err) {
556
+ // 响应已发一半(客户端中断/上游竞态)时绝不能再次写头, 否则进程崩溃
557
+ try {
558
+ if (!res.headersSent) {
559
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
560
+ res.end(JSON.stringify({ error: 'internal', detail: String(err?.message || err) }))
561
+ } else {
562
+ res.destroy()
563
+ }
564
+ } catch {}
565
+ }
566
+ })
567
+
568
+ // 最后一层护栏: 任何未捕获异常只记录不退出(网关单点服务, 不能因单请求竞态离线)
569
+ process.on('uncaughtException', (err) => {
570
+ try { console.error('[uncaughtException]', err?.stack || String(err)) } catch {}
571
+ })
572
+ process.on('unhandledRejection', (err) => {
573
+ try { console.error('[unhandledRejection]', err?.stack || String(err)) } catch {}
574
+ })
575
+
576
+ server.on('upgrade', (req, socket, head) => {
577
+ const url = new URL(req.url, 'http://dsh-remote.local')
578
+ if (!url.pathname.startsWith('/api/')) {
579
+ socket.destroy()
580
+ return
581
+ }
582
+ const ok = authorized(req, url)
583
+ const channel = url.pathname.includes('events.mux') ? 'mux' : url.pathname.includes('events.host') ? 'host' : null
584
+ const d = touchDevice(req, ok && channel ? { channel } : { failedAuth: !ok })
585
+ if (d) d.sockets.add(socket)
586
+ const release = () => {
587
+ d.sockets.delete(socket)
588
+ if (channel) d.channels[channel] = false
589
+ try { socket.destroy() } catch {}
590
+ }
591
+ socket.on('close', release)
592
+ if (!ok) {
593
+ authFailures++
594
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
595
+ release()
596
+ return
597
+ }
598
+
599
+ const headers = {}
600
+ for (const [k, v] of Object.entries(req.headers)) {
601
+ if (v === undefined) continue
602
+ const key = k.toLowerCase()
603
+ if (['host', 'authorization', 'connection', 'upgrade', 'sec-websocket-key',
604
+ 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol',
605
+ 'proxy-connection', 'accept-encoding', 'origin', 'referer',
606
+ 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user',
607
+ 'x-dsh-remote-client'].includes(key)) continue
608
+ headers[k] = v
609
+ }
610
+ headers.host = UPSTREAM.host
611
+ headers.connection = 'Upgrade'
612
+ headers.upgrade = 'websocket'
613
+ if (req.headers['sec-websocket-key']) headers['sec-websocket-key'] = req.headers['sec-websocket-key']
614
+ if (req.headers['sec-websocket-version']) headers['sec-websocket-version'] = req.headers['sec-websocket-version']
615
+ if (req.headers['sec-websocket-protocol']) headers['sec-websocket-protocol'] = req.headers['sec-websocket-protocol']
616
+ if (req.headers['sec-websocket-extensions']) headers['sec-websocket-extensions'] = req.headers['sec-websocket-extensions']
617
+
618
+ const upstreamReq = http.request({
619
+ hostname: UPSTREAM.hostname,
620
+ port: UPSTREAM.port,
621
+ method: req.method,
622
+ path: url.pathname + url.search,
623
+ headers
624
+ })
625
+
626
+ upstreamReq.on('upgrade', (upRes, upSocket, upHead) => {
627
+ if (socket.destroyed) { upSocket.destroy(); return }
628
+ const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`]
629
+ for (const [k, v] of Object.entries(upRes.headers)) {
630
+ if (Array.isArray(v)) for (const vv of v) lines.push(`${k}: ${vv}`)
631
+ else if (v !== undefined) lines.push(`${k}: ${v}`)
632
+ }
633
+ lines.push('', '')
634
+ socket.write(lines.join('\r\n'))
635
+ if (upHead?.length) upSocket.unshift(upHead)
636
+ if (head?.length) socket.unshift(head)
637
+ socket.setNoDelay(true)
638
+ upSocket.setNoDelay(true)
639
+ upSocket.pipe(socket)
640
+ socket.pipe(upSocket)
641
+ const close = () => { upSocket.destroy(); socket.destroy() }
642
+ upSocket.on('error', close)
643
+ socket.on('error', close)
644
+ upSocket.on('close', () => socket.end())
645
+ socket.on('close', () => upSocket.end())
646
+ })
647
+
648
+ upstreamReq.on('error', () => {
649
+ if (!socket.destroyed) {
650
+ socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
651
+ socket.destroy()
652
+ }
653
+ })
654
+ upstreamReq.end()
655
+ })
656
+
657
+ server.on('clientError', (err, socket) => {
658
+ if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
659
+ })
660
+
661
+ server.listen(PORT, HOST, () => {
662
+ console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
663
+ console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + TOKEN)
664
+ for (const ip of lanAddresses()) {
665
+ console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + TOKEN)
666
+ }
667
+ console.log(' 管理页: http://127.0.0.1:' + PORT + '/admin')
668
+ if (HOST === '127.0.0.1') {
669
+ console.log(' 提示: 监听在 127.0.0.1, 手机请改用 Tailscale serve 或设置 HOST=0.0.0.0')
670
+ }
671
+ console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
672
+ // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
673
+ setTimeout(() => checkForUpdates(false), 8000)
674
+ setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
675
+ })