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/README.md +34 -0
- package/client.js +140 -0
- package/cordis.patch.yml +4 -0
- package/gateway.cjs +675 -0
- package/index.mjs +392 -0
- package/package.json +56 -0
- package/public/admin.html +89 -0
- package/public/admin.js +265 -0
- package/public/app.js +1163 -0
- package/public/icon.svg +12 -0
- package/public/index.html +176 -0
- package/public/manifest.webmanifest +16 -0
- package/public/styles.css +396 -0
- package/public/update.json +6 -0
- package/public/version.json +3 -0
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
|
+
})
|