dsh-remote-plugin 0.6.12 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +353 -21
- package/index.mjs +30 -5
- package/package.json +1 -1
- package/public/admin.html +157 -8
- package/public/admin.js +264 -5
- package/public/announcements.json +52 -0
- package/public/app.js +515 -48
- package/public/desktop/desktop.css +25 -0
- package/public/desktop/desktop.html +12 -6
- package/public/desktop/desktop.js +225 -36
- package/public/index.html +61 -16
- package/public/md.js +71 -1
- package/public/styles.css +50 -0
- package/public/transcribe-core.js +74 -0
- package/public/update.json +12 -12
- package/public/version.json +1 -1
package/apk/dsh-remote.apk
CHANGED
|
Binary file
|
package/gateway.cjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* DSH_UPSTREAM DSH web 服务地址, 默认 http://127.0.0.1:3080
|
|
19
19
|
* TOKEN 访问令牌; 不设置则读 TOKEN_FILE, 仍没有则自动生成
|
|
20
20
|
* TOKEN_FILE 令牌文件, 默认 ~/.dsh-remote/token
|
|
21
|
+
* DSH_REMOTE_DEVICE_KEYS 独立设备密钥状态文件, 默认 ~/.dsh-remote/device-keys.json
|
|
21
22
|
* DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
|
|
22
23
|
* DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
|
|
23
24
|
* DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
|
|
@@ -77,6 +78,7 @@ const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/
|
|
|
77
78
|
: '/' + String(process.env.DSH_HEALTH_PATH)
|
|
78
79
|
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
|
|
79
80
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
81
|
+
const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
|
|
80
82
|
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
81
83
|
const STARTED_AT = Date.now()
|
|
82
84
|
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
@@ -102,6 +104,17 @@ function gatewayVersion() {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
const VERSION = gatewayVersion()
|
|
107
|
+
const PROTOCOL_VERSION = 1
|
|
108
|
+
const CAPABILITIES = Object.freeze({
|
|
109
|
+
wsTicket: 1,
|
|
110
|
+
eventPolling: 1,
|
|
111
|
+
workspaceFiles: 2,
|
|
112
|
+
imagePromptTransport: 1,
|
|
113
|
+
dshLifecycle: 2,
|
|
114
|
+
centralAnnouncements: 2,
|
|
115
|
+
feedback: 1,
|
|
116
|
+
deviceKeys: 1,
|
|
117
|
+
})
|
|
105
118
|
|
|
106
119
|
const MIME = {
|
|
107
120
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -208,10 +221,118 @@ let TOKEN = loadToken()
|
|
|
208
221
|
const WS_TICKET_TTL_MS = durationEnv('GATEWAY_WS_TICKET_TTL_MS', 90000, 10000, 10 * 60 * 1000)
|
|
209
222
|
const wsTickets = new Map()
|
|
210
223
|
|
|
224
|
+
function newAccessToken() {
|
|
225
|
+
return crypto.randomBytes(24).toString('base64url')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function safeTokenEqual(left, right) {
|
|
229
|
+
const a = Buffer.from(String(left || ''))
|
|
230
|
+
const b = Buffer.from(String(right || ''))
|
|
231
|
+
return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeDeviceKey(value) {
|
|
235
|
+
if (!value || typeof value !== 'object') return null
|
|
236
|
+
const id = String(value.id || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
|
|
237
|
+
const accessToken = String(value.token || '').trim()
|
|
238
|
+
if (!id || accessToken.length < 16 || accessToken.length > 256) return null
|
|
239
|
+
return {
|
|
240
|
+
id,
|
|
241
|
+
note: String(value.note || '').trim().slice(0, 40),
|
|
242
|
+
token: accessToken,
|
|
243
|
+
createdAt: Number(value.createdAt) || Date.now(),
|
|
244
|
+
updatedAt: Number(value.updatedAt) || Number(value.createdAt) || Date.now(),
|
|
245
|
+
lastUsedAt: Number(value.lastUsedAt) || 0,
|
|
246
|
+
lastIp: String(value.lastIp || '').slice(0, 128),
|
|
247
|
+
lastKind: String(value.lastKind || '').slice(0, 24),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function loadDeviceKeys() {
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(fs.readFileSync(DEVICE_KEYS_FILE, 'utf8'))
|
|
254
|
+
return {
|
|
255
|
+
enabled: parsed?.enabled === true,
|
|
256
|
+
keys: Array.isArray(parsed?.keys) ? parsed.keys.map(normalizeDeviceKey).filter(Boolean).slice(0, 100) : [],
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
return { enabled: false, keys: [] }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const deviceKeyState = loadDeviceKeys()
|
|
264
|
+
let deviceKeysSaveTimer = null
|
|
265
|
+
|
|
266
|
+
function saveDeviceKeys() {
|
|
267
|
+
try {
|
|
268
|
+
fs.mkdirSync(path.dirname(DEVICE_KEYS_FILE), { recursive: true })
|
|
269
|
+
fs.writeFileSync(DEVICE_KEYS_FILE, JSON.stringify({ version: 1, ...deviceKeyState }, null, 2) + '\n', { mode: 0o600 })
|
|
270
|
+
try { fs.chmodSync(DEVICE_KEYS_FILE, 0o600) } catch {}
|
|
271
|
+
return true
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.warn('[device-keys] 保存失败: ' + (err?.message || err))
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function scheduleDeviceKeysSave() {
|
|
279
|
+
if (deviceKeysSaveTimer) return
|
|
280
|
+
deviceKeysSaveTimer = setTimeout(() => {
|
|
281
|
+
deviceKeysSaveTimer = null
|
|
282
|
+
saveDeviceKeys()
|
|
283
|
+
}, 500)
|
|
284
|
+
deviceKeysSaveTimer.unref?.()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createDeviceKey(note = '') {
|
|
288
|
+
const now = Date.now()
|
|
289
|
+
const record = {
|
|
290
|
+
id: crypto.randomUUID?.() || crypto.randomBytes(16).toString('hex'),
|
|
291
|
+
note: String(note || '').trim().slice(0, 40) || '新设备',
|
|
292
|
+
token: newAccessToken(),
|
|
293
|
+
createdAt: now,
|
|
294
|
+
updatedAt: now,
|
|
295
|
+
lastUsedAt: 0,
|
|
296
|
+
lastIp: '',
|
|
297
|
+
lastKind: '',
|
|
298
|
+
}
|
|
299
|
+
deviceKeyState.keys.push(record)
|
|
300
|
+
if (!saveDeviceKeys()) {
|
|
301
|
+
deviceKeyState.keys.pop()
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
return record
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function deviceKeyViews() {
|
|
308
|
+
return deviceKeyState.keys.map(record => ({ ...record }))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findDeviceKeyByToken(value) {
|
|
312
|
+
return deviceKeyState.keys.find(record => safeTokenEqual(value, record.token)) || null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function authKind(req) {
|
|
316
|
+
const marked = String(req.headers['x-dsh-remote-client'] || '')
|
|
317
|
+
return marked === 'app' || marked === 'web' || marked === 'admin' ? marked : kindOf(req)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function rememberDeviceKeyUse(record, req) {
|
|
321
|
+
if (!record) return
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
const nextIp = ipOf(req)
|
|
324
|
+
const nextKind = authKind(req)
|
|
325
|
+
const needsSave = now - record.lastUsedAt > 30_000 || record.lastIp !== nextIp || record.lastKind !== nextKind
|
|
326
|
+
record.lastUsedAt = now
|
|
327
|
+
record.lastIp = nextIp
|
|
328
|
+
record.lastKind = nextKind
|
|
329
|
+
if (needsSave) scheduleDeviceKeysSave()
|
|
330
|
+
}
|
|
331
|
+
|
|
211
332
|
/** 一键轮换令牌: 写回 TOKEN_FILE 并立即生效(旧令牌/旧连接全部失效)。 */
|
|
212
333
|
function rotateToken() {
|
|
213
334
|
if (TOKEN_FROM_ENV) return { error: 'token-from-env', detail: '令牌来自 TOKEN 环境变量, 请修改环境变量后重启' }
|
|
214
|
-
const next =
|
|
335
|
+
const next = newAccessToken()
|
|
215
336
|
try {
|
|
216
337
|
fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
|
|
217
338
|
fs.writeFileSync(TOKEN_FILE, next + '\n', { mode: 0o600 })
|
|
@@ -231,11 +352,24 @@ function tokenOf(req, url) {
|
|
|
231
352
|
}
|
|
232
353
|
|
|
233
354
|
function authorized(req, url, options = {}) {
|
|
234
|
-
|
|
355
|
+
const presented = tokenOf(req, url)
|
|
356
|
+
if (!deviceKeyState.enabled && safeTokenEqual(presented, TOKEN)) {
|
|
357
|
+
req.dshRemoteAuth = { type: 'shared', id: 'shared' }
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
if (deviceKeyState.enabled) {
|
|
361
|
+
const record = findDeviceKeyByToken(presented)
|
|
362
|
+
if (record) {
|
|
363
|
+
req.dshRemoteAuth = { type: 'device', id: record.id }
|
|
364
|
+
rememberDeviceKeyUse(record, req)
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
}
|
|
235
368
|
if (options.consumeTicket) {
|
|
236
369
|
const ticket = url.searchParams.get('ticket')
|
|
237
370
|
const record = ticket && wsTickets.get(ticket)
|
|
238
371
|
if (record && record.expiresAt > Date.now()) {
|
|
372
|
+
req.dshRemoteAuth = record.auth || { type: 'shared', id: 'shared' }
|
|
239
373
|
record.uses--
|
|
240
374
|
if (record.uses <= 0) wsTickets.delete(ticket)
|
|
241
375
|
return true
|
|
@@ -245,18 +379,29 @@ function authorized(req, url, options = {}) {
|
|
|
245
379
|
return false
|
|
246
380
|
}
|
|
247
381
|
|
|
248
|
-
function
|
|
382
|
+
function adminAuthorized(req, url) {
|
|
383
|
+
const ok = safeTokenEqual(tokenOf(req, url), TOKEN)
|
|
384
|
+
if (ok) req.dshRemoteAuth = { type: 'admin', id: 'admin' }
|
|
385
|
+
return ok
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function controlAuthorized(req, url) {
|
|
389
|
+
return adminAuthorized(req, url) || authorized(req, url)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function issueWsTicket(auth) {
|
|
249
393
|
const now = Date.now()
|
|
250
394
|
for (const [ticket, record] of wsTickets) {
|
|
251
395
|
if (record.expiresAt <= now) wsTickets.delete(ticket)
|
|
252
396
|
}
|
|
253
397
|
const ticket = crypto.randomBytes(24).toString('base64url')
|
|
254
|
-
wsTickets.set(ticket, { expiresAt: now + WS_TICKET_TTL_MS, uses: 4 })
|
|
398
|
+
wsTickets.set(ticket, { expiresAt: now + WS_TICKET_TTL_MS, uses: 4, auth: auth || { type: 'shared', id: 'shared' } })
|
|
255
399
|
return { ticket, expiresAt: now + WS_TICKET_TTL_MS }
|
|
256
400
|
}
|
|
257
401
|
|
|
258
402
|
// ---------- 设备监控 ----------
|
|
259
|
-
const devices = new Map() // ip -> device
|
|
403
|
+
const devices = new Map() // ip[|clientId] -> device
|
|
404
|
+
const legacyDeviceAliases = new Map() // ip -> { clientId, ua, expiresAt }
|
|
260
405
|
// 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
|
|
261
406
|
// online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
|
|
262
407
|
const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
@@ -273,6 +418,9 @@ function pruneDevices(now = Date.now()) {
|
|
|
273
418
|
for (const [ip, d] of devices) {
|
|
274
419
|
if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
|
|
275
420
|
}
|
|
421
|
+
for (const [ip, alias] of legacyDeviceAliases) {
|
|
422
|
+
if (!alias || alias.expiresAt <= now) legacyDeviceAliases.delete(ip)
|
|
423
|
+
}
|
|
276
424
|
}
|
|
277
425
|
|
|
278
426
|
function loadNotes() {
|
|
@@ -300,20 +448,71 @@ function kindOf(req) {
|
|
|
300
448
|
return 'browser'
|
|
301
449
|
}
|
|
302
450
|
|
|
451
|
+
function mergeDeviceRecords(target, legacy) {
|
|
452
|
+
if (!target || !legacy || target === legacy) return
|
|
453
|
+
target.firstSeen = Math.min(target.firstSeen || Date.now(), legacy.firstSeen || Date.now())
|
|
454
|
+
target.lastSeen = Math.max(target.lastSeen || 0, legacy.lastSeen || 0)
|
|
455
|
+
target.requests += legacy.requests || 0
|
|
456
|
+
target.authFailures += legacy.authFailures || 0
|
|
457
|
+
target.credentialId ||= legacy.credentialId || ''
|
|
458
|
+
if (!target.ua || (legacy.ua && legacy.ua.length > target.ua.length)) target.ua = legacy.ua
|
|
459
|
+
for (const channel of new Set([...Object.keys(legacy.channelCounts || {}), ...Object.keys(target.channelCounts || {})])) {
|
|
460
|
+
target.channelCounts[channel] = (target.channelCounts[channel] || 0) + (legacy.channelCounts?.[channel] || 0)
|
|
461
|
+
target.channels[channel] = !!(target.channelCounts[channel] || target.channels[channel] || legacy.channels?.[channel])
|
|
462
|
+
}
|
|
463
|
+
for (const socket of legacy.sockets || []) target.sockets.add(socket)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function legacyDeviceFor(ip, clientId, req) {
|
|
467
|
+
if (!clientId) return null
|
|
468
|
+
const legacy = devices.get(ip)
|
|
469
|
+
if (!legacy || legacy.clientId) return null
|
|
470
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
471
|
+
const sameUa = requestUa && legacy.ua && requestUa === legacy.ua
|
|
472
|
+
const legacyBackgroundPoll = /^Dalvik\/2\.1\.0/i.test(legacy.ua || '') && req.headers['x-dsh-remote-client'] === 'app'
|
|
473
|
+
if (!sameUa && !legacyBackgroundPoll) return null
|
|
474
|
+
return legacy
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function knownDeviceForLegacy(ip, req) {
|
|
478
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
479
|
+
if (!requestUa) return null
|
|
480
|
+
const candidates = [...devices.values()].filter(d => {
|
|
481
|
+
if (d.ip !== ip || !d.clientId) return false
|
|
482
|
+
return (d.ua && d.ua === requestUa) || (d.kind === 'app' && /^Dalvik\/2\.1\.0/i.test(requestUa))
|
|
483
|
+
})
|
|
484
|
+
return candidates.length === 1 ? candidates[0] : null
|
|
485
|
+
}
|
|
486
|
+
|
|
303
487
|
function touchDevice(req, extra = {}) {
|
|
304
488
|
pruneDevices()
|
|
305
489
|
const ip = ipOf(req)
|
|
306
|
-
const
|
|
490
|
+
const headerClientId = req.headers['x-dsh-remote-client-id']
|
|
491
|
+
let clientId = String(extra.clientId || headerClientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
|
|
492
|
+
const requestUa = String(req.headers['user-agent'] || '')
|
|
493
|
+
if (!clientId) {
|
|
494
|
+
const alias = legacyDeviceAliases.get(ip)
|
|
495
|
+
if (alias && alias.expiresAt > Date.now() && alias.ua && alias.ua === requestUa) clientId = alias.clientId
|
|
496
|
+
else clientId = knownDeviceForLegacy(ip, req)?.clientId || ''
|
|
497
|
+
}
|
|
307
498
|
const deviceKey = clientId ? `${ip}|${clientId}` : ip
|
|
308
499
|
totalRequests++
|
|
309
500
|
let d = devices.get(deviceKey)
|
|
310
501
|
if (!d) {
|
|
311
502
|
d = {
|
|
312
503
|
id: deviceKey, ip, clientId, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
|
|
313
|
-
requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
|
|
504
|
+
credentialId: '', requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
|
|
314
505
|
}
|
|
315
506
|
devices.set(deviceKey, d)
|
|
316
507
|
}
|
|
508
|
+
if (clientId && deviceKey !== ip) {
|
|
509
|
+
const legacy = legacyDeviceFor(ip, clientId, req)
|
|
510
|
+
if (legacy && legacy !== d) {
|
|
511
|
+
mergeDeviceRecords(d, legacy)
|
|
512
|
+
devices.delete(ip)
|
|
513
|
+
legacyDeviceAliases.set(ip, { clientId, ua: legacy.ua, expiresAt: Date.now() + DEVICE_TTL_MS })
|
|
514
|
+
}
|
|
515
|
+
}
|
|
317
516
|
d.lastSeen = Date.now()
|
|
318
517
|
d.requests++
|
|
319
518
|
if (extra.channel) {
|
|
@@ -326,9 +525,10 @@ function touchDevice(req, extra = {}) {
|
|
|
326
525
|
d.channels[extra.closeChannel] = count > 0
|
|
327
526
|
}
|
|
328
527
|
if (extra.failedAuth) d.authFailures++
|
|
528
|
+
if (req.dshRemoteAuth?.type === 'device') d.credentialId = req.dshRemoteAuth.id
|
|
329
529
|
const marked = req.headers['x-dsh-remote-client']
|
|
330
530
|
if (marked) d.kind = marked
|
|
331
|
-
const ua =
|
|
531
|
+
const ua = requestUa
|
|
332
532
|
if (ua && ua.length > d.ua.length) d.ua = ua
|
|
333
533
|
return d
|
|
334
534
|
}
|
|
@@ -339,6 +539,7 @@ function deviceViews() {
|
|
|
339
539
|
ip: d.ip,
|
|
340
540
|
id: d.id,
|
|
341
541
|
clientId: d.clientId || '',
|
|
542
|
+
credentialId: d.credentialId || '',
|
|
342
543
|
note: deviceNotes[d.ip] || '',
|
|
343
544
|
kind: d.kind,
|
|
344
545
|
ua: d.ua,
|
|
@@ -369,6 +570,44 @@ function kickDevice(ip) {
|
|
|
369
570
|
return n
|
|
370
571
|
}
|
|
371
572
|
|
|
573
|
+
function kickCredential(credentialId) {
|
|
574
|
+
const targets = [...devices.values()].filter(d => d.credentialId === credentialId)
|
|
575
|
+
let n = 0
|
|
576
|
+
for (const d of targets) {
|
|
577
|
+
for (const sock of d.sockets) {
|
|
578
|
+
try { sock.destroy() } catch {}
|
|
579
|
+
n++
|
|
580
|
+
}
|
|
581
|
+
d.sockets.clear()
|
|
582
|
+
d.channels = {}
|
|
583
|
+
d.channelCounts = {}
|
|
584
|
+
}
|
|
585
|
+
return n
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function kickRemoteClients() {
|
|
589
|
+
let n = 0
|
|
590
|
+
for (const d of devices.values()) {
|
|
591
|
+
if (d.kind === 'admin') continue
|
|
592
|
+
for (const sock of d.sockets) {
|
|
593
|
+
try { sock.destroy() } catch {}
|
|
594
|
+
n++
|
|
595
|
+
}
|
|
596
|
+
d.sockets.clear()
|
|
597
|
+
d.channels = {}
|
|
598
|
+
d.channelCounts = {}
|
|
599
|
+
}
|
|
600
|
+
return n
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function deviceKeysPayload() {
|
|
604
|
+
return {
|
|
605
|
+
supported: true,
|
|
606
|
+
enabled: deviceKeyState.enabled,
|
|
607
|
+
entries: deviceKeyViews(),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
372
611
|
// ---------- GitHub/镜像 更新检查 ----------
|
|
373
612
|
function parseVersion(v) {
|
|
374
613
|
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
|
|
@@ -529,7 +768,7 @@ function cors(res, req = res.req) {
|
|
|
529
768
|
}
|
|
530
769
|
if (allowed) res.setHeader('access-control-allow-origin', origin || '*')
|
|
531
770
|
res.setHeader('vary', 'Origin')
|
|
532
|
-
res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client')
|
|
771
|
+
res.setHeader('access-control-allow-headers', 'authorization, content-type, x-dsh-remote-client, x-dsh-remote-client-id')
|
|
533
772
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
534
773
|
res.setHeader('access-control-max-age', '600')
|
|
535
774
|
}
|
|
@@ -810,7 +1049,7 @@ async function serveDshControl(req, res, url) {
|
|
|
810
1049
|
res.end()
|
|
811
1050
|
return
|
|
812
1051
|
}
|
|
813
|
-
if (!
|
|
1052
|
+
if (!controlAuthorized(req, url)) {
|
|
814
1053
|
authFailures++
|
|
815
1054
|
touchDevice(req, { failedAuth: true })
|
|
816
1055
|
cors(res)
|
|
@@ -998,7 +1237,7 @@ function serveWsTicket(req, res, url) {
|
|
|
998
1237
|
}
|
|
999
1238
|
cors(res, req)
|
|
1000
1239
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1001
|
-
res.end(JSON.stringify({ ok: true, ...issueWsTicket() }))
|
|
1240
|
+
res.end(JSON.stringify({ ok: true, ...issueWsTicket(req.dshRemoteAuth) }))
|
|
1002
1241
|
}
|
|
1003
1242
|
|
|
1004
1243
|
function serveEventPoll(req, res, url) {
|
|
@@ -1597,7 +1836,7 @@ function serveAdminApi(req, res, url) {
|
|
|
1597
1836
|
const sub = url.pathname.slice('/admin/api'.length) || '/'
|
|
1598
1837
|
if (sub === '/dsh') return serveDshControl(req, res, url)
|
|
1599
1838
|
if (sub === '/state' && req.method === 'GET') {
|
|
1600
|
-
if (!
|
|
1839
|
+
if (!adminAuthorized(req, url)) {
|
|
1601
1840
|
authFailures++
|
|
1602
1841
|
touchDevice(req, { failedAuth: true })
|
|
1603
1842
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1611,12 +1850,15 @@ function serveAdminApi(req, res, url) {
|
|
|
1611
1850
|
mode: 'gateway',
|
|
1612
1851
|
version: VERSION,
|
|
1613
1852
|
pid: process.pid,
|
|
1853
|
+
platform: process.platform,
|
|
1614
1854
|
hostname: os.hostname(),
|
|
1615
1855
|
lanIPs: lanAddresses(),
|
|
1616
1856
|
startedAt: STARTED_AT,
|
|
1617
1857
|
uptimeSec: Math.round((Date.now() - STARTED_AT) / 1000),
|
|
1618
1858
|
host: HOST,
|
|
1619
1859
|
port: PORT,
|
|
1860
|
+
protocol: { version: PROTOCOL_VERSION },
|
|
1861
|
+
capabilities: CAPABILITIES,
|
|
1620
1862
|
upstream: { url: UPSTREAM.origin, reachable },
|
|
1621
1863
|
latest: {
|
|
1622
1864
|
version: latestState.version,
|
|
@@ -1630,17 +1872,106 @@ function serveAdminApi(req, res, url) {
|
|
|
1630
1872
|
tokenFromEnv: TOKEN_FROM_ENV,
|
|
1631
1873
|
tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
|
|
1632
1874
|
tokenLength: TOKEN.length,
|
|
1875
|
+
deviceKeys: deviceKeysPayload(),
|
|
1633
1876
|
totalRequests,
|
|
1634
1877
|
authFailures,
|
|
1635
1878
|
deviceCount: devices.size,
|
|
1636
1879
|
onlineCount: [...devices.values()].filter(d => Date.now() - d.lastSeen < 60_000).length,
|
|
1880
|
+
events: eventCollectorState,
|
|
1637
1881
|
devices: deviceViews()
|
|
1638
1882
|
}))
|
|
1639
1883
|
})
|
|
1640
1884
|
return
|
|
1641
1885
|
}
|
|
1886
|
+
if (sub.startsWith('/device-keys/') && req.method === 'POST') {
|
|
1887
|
+
if (!adminAuthorized(req, url)) {
|
|
1888
|
+
authFailures++
|
|
1889
|
+
touchDevice(req, { failedAuth: true })
|
|
1890
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
1891
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
1892
|
+
return
|
|
1893
|
+
}
|
|
1894
|
+
let body = ''
|
|
1895
|
+
req.on('data', chunk => {
|
|
1896
|
+
body += chunk
|
|
1897
|
+
if (body.length > 8192) req.destroy()
|
|
1898
|
+
})
|
|
1899
|
+
req.on('end', () => {
|
|
1900
|
+
let payload = {}
|
|
1901
|
+
try { payload = JSON.parse(body || '{}') } catch {
|
|
1902
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
1903
|
+
res.end(JSON.stringify({ error: 'bad-request', detail: '请求内容不是有效 JSON' }))
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
const send = (status, value) => {
|
|
1907
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1908
|
+
res.end(JSON.stringify(value))
|
|
1909
|
+
}
|
|
1910
|
+
if (sub === '/device-keys/mode') {
|
|
1911
|
+
if (typeof payload.enabled !== 'boolean') return send(400, { error: 'bad-request', detail: 'enabled 必须是布尔值' })
|
|
1912
|
+
if (payload.enabled && deviceKeyState.keys.length === 0 && !createDeviceKey(payload.note || '我的设备')) {
|
|
1913
|
+
return send(500, { error: 'write-failed', detail: '无法创建首个设备密钥' })
|
|
1914
|
+
}
|
|
1915
|
+
const previous = deviceKeyState.enabled
|
|
1916
|
+
deviceKeyState.enabled = payload.enabled
|
|
1917
|
+
if (!saveDeviceKeys()) {
|
|
1918
|
+
deviceKeyState.enabled = previous
|
|
1919
|
+
return send(500, { error: 'write-failed', detail: '无法保存独立设备密钥设置' })
|
|
1920
|
+
}
|
|
1921
|
+
wsTickets.clear()
|
|
1922
|
+
const disconnected = kickRemoteClients()
|
|
1923
|
+
return send(200, { ok: true, disconnected, deviceKeys: deviceKeysPayload() })
|
|
1924
|
+
}
|
|
1925
|
+
if (sub === '/device-keys/create') {
|
|
1926
|
+
if (deviceKeyState.keys.length >= 100) return send(409, { error: 'too-many-device-keys', detail: '设备密钥数量已达到上限' })
|
|
1927
|
+
const record = createDeviceKey(payload.note)
|
|
1928
|
+
return record
|
|
1929
|
+
? send(201, { ok: true, entry: { ...record }, deviceKeys: deviceKeysPayload() })
|
|
1930
|
+
: send(500, { error: 'write-failed', detail: '无法保存设备密钥' })
|
|
1931
|
+
}
|
|
1932
|
+
const id = String(payload.id || '').trim()
|
|
1933
|
+
const index = deviceKeyState.keys.findIndex(record => record.id === id)
|
|
1934
|
+
if (index < 0) return send(404, { error: 'device-key-not-found', detail: '找不到该设备密钥' })
|
|
1935
|
+
const record = deviceKeyState.keys[index]
|
|
1936
|
+
if (sub === '/device-keys/note') {
|
|
1937
|
+
const previous = { note: record.note, updatedAt: record.updatedAt }
|
|
1938
|
+
record.note = String(payload.note || '').trim().slice(0, 40)
|
|
1939
|
+
record.updatedAt = Date.now()
|
|
1940
|
+
if (!saveDeviceKeys()) {
|
|
1941
|
+
Object.assign(record, previous)
|
|
1942
|
+
return send(500, { error: 'write-failed', detail: '无法保存备注' })
|
|
1943
|
+
}
|
|
1944
|
+
return send(200, { ok: true, entry: { ...record } })
|
|
1945
|
+
}
|
|
1946
|
+
if (sub === '/device-keys/rotate') {
|
|
1947
|
+
const previous = { token: record.token, updatedAt: record.updatedAt, lastUsedAt: record.lastUsedAt }
|
|
1948
|
+
record.token = newAccessToken()
|
|
1949
|
+
record.updatedAt = Date.now()
|
|
1950
|
+
record.lastUsedAt = 0
|
|
1951
|
+
if (!saveDeviceKeys()) {
|
|
1952
|
+
Object.assign(record, previous)
|
|
1953
|
+
return send(500, { error: 'write-failed', detail: '无法轮换设备令牌' })
|
|
1954
|
+
}
|
|
1955
|
+
wsTickets.clear()
|
|
1956
|
+
const disconnected = kickCredential(record.id)
|
|
1957
|
+
return send(200, { ok: true, disconnected, entry: { ...record } })
|
|
1958
|
+
}
|
|
1959
|
+
if (sub === '/device-keys/revoke') {
|
|
1960
|
+
deviceKeyState.keys.splice(index, 1)
|
|
1961
|
+
if (!saveDeviceKeys()) {
|
|
1962
|
+
deviceKeyState.keys.splice(index, 0, record)
|
|
1963
|
+
return send(500, { error: 'write-failed', detail: '无法退出设备' })
|
|
1964
|
+
}
|
|
1965
|
+
wsTickets.clear()
|
|
1966
|
+
const disconnected = kickCredential(record.id)
|
|
1967
|
+
return send(200, { ok: true, disconnected, id: record.id })
|
|
1968
|
+
}
|
|
1969
|
+
return send(404, { error: 'not-found' })
|
|
1970
|
+
})
|
|
1971
|
+
return
|
|
1972
|
+
}
|
|
1642
1973
|
if (sub === '/token/rotate' && req.method === 'POST') {
|
|
1643
|
-
if (!
|
|
1974
|
+
if (!adminAuthorized(req, url)) {
|
|
1644
1975
|
authFailures++
|
|
1645
1976
|
touchDevice(req, { failedAuth: true })
|
|
1646
1977
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1654,16 +1985,14 @@ function serveAdminApi(req, res, url) {
|
|
|
1654
1985
|
return
|
|
1655
1986
|
}
|
|
1656
1987
|
// 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
|
|
1657
|
-
|
|
1658
|
-
if (d.kind !== 'admin') kickDevice(d.ip)
|
|
1659
|
-
}
|
|
1988
|
+
kickRemoteClients()
|
|
1660
1989
|
touchDevice(req)
|
|
1661
1990
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
1662
1991
|
res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
|
|
1663
1992
|
return
|
|
1664
1993
|
}
|
|
1665
1994
|
if (sub === '/shutdown' && req.method === 'POST') {
|
|
1666
|
-
if (!
|
|
1995
|
+
if (!adminAuthorized(req, url)) {
|
|
1667
1996
|
authFailures++
|
|
1668
1997
|
touchDevice(req, { failedAuth: true })
|
|
1669
1998
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
@@ -1680,7 +2009,7 @@ function serveAdminApi(req, res, url) {
|
|
|
1680
2009
|
return
|
|
1681
2010
|
}
|
|
1682
2011
|
if (sub === '/note' && req.method === 'POST') {
|
|
1683
|
-
if (!
|
|
2012
|
+
if (!adminAuthorized(req, url)) {
|
|
1684
2013
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
1685
2014
|
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
1686
2015
|
return
|
|
@@ -1705,7 +2034,7 @@ function serveAdminApi(req, res, url) {
|
|
|
1705
2034
|
return
|
|
1706
2035
|
}
|
|
1707
2036
|
if (sub === '/kick' && req.method === 'POST') {
|
|
1708
|
-
if (!
|
|
2037
|
+
if (!adminAuthorized(req, url)) {
|
|
1709
2038
|
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
1710
2039
|
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
1711
2040
|
return
|
|
@@ -2694,6 +3023,8 @@ async function serveHealth(res) {
|
|
|
2694
3023
|
ok: true,
|
|
2695
3024
|
service: 'dsh-remote',
|
|
2696
3025
|
version: VERSION,
|
|
3026
|
+
protocol: { version: PROTOCOL_VERSION },
|
|
3027
|
+
capabilities: CAPABILITIES,
|
|
2697
3028
|
pid: process.pid,
|
|
2698
3029
|
upstream: UPSTREAM.origin,
|
|
2699
3030
|
upstreamProbe: DSH_HEALTH_PATH,
|
|
@@ -3041,10 +3372,11 @@ server.on('clientError', (err, socket) => {
|
|
|
3041
3372
|
})
|
|
3042
3373
|
|
|
3043
3374
|
server.listen(PORT, HOST, () => {
|
|
3375
|
+
const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
|
|
3044
3376
|
console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
|
|
3045
|
-
console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' +
|
|
3377
|
+
console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
|
|
3046
3378
|
for (const ip of lanAddresses()) {
|
|
3047
|
-
console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' +
|
|
3379
|
+
console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
|
|
3048
3380
|
}
|
|
3049
3381
|
console.log(' 管理页: http://127.0.0.1:' + PORT + '/admin')
|
|
3050
3382
|
if (HOST === '127.0.0.1') {
|
package/index.mjs
CHANGED
|
@@ -151,7 +151,7 @@ function runExit(cmd, args) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
const GATEWAY_ENV_KEYS = [
|
|
154
|
-
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
154
|
+
'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_DEVICE_KEYS', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 'DSH_REMOTE_FS_MAX_UPLOAD',
|
|
155
155
|
'DSH_REMOTE_NOTES', 'DSH_REMOTE_WORKBENCH', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_SYSTEMCTL',
|
|
156
156
|
'DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 'DSH_REMOTE_DSH_CONTROL_POLL_MS', 'DSH_REMOTE_FEEDBACK_URL',
|
|
157
157
|
'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'DSH_HEALTH_PATH',
|
|
@@ -534,32 +534,57 @@ async function serveStatic(req, res, ctx) {
|
|
|
534
534
|
version,
|
|
535
535
|
token: localToken || '',
|
|
536
536
|
gatewayInstalled,
|
|
537
|
+
platform: process.platform,
|
|
537
538
|
hostname: hostname(),
|
|
538
539
|
lanIPs: lanIPs(),
|
|
539
540
|
startedAt: Date.now() - Math.floor(process.uptime() * 1000),
|
|
540
541
|
uptimeSec: Math.floor(process.uptime()),
|
|
541
542
|
host: dshListen.host,
|
|
542
543
|
port: dshListen.port,
|
|
544
|
+
protocol: { version: 1 },
|
|
545
|
+
capabilities: {
|
|
546
|
+
wsTicket: 0,
|
|
547
|
+
eventPolling: 0,
|
|
548
|
+
workspaceFiles: 0,
|
|
549
|
+
imagePromptTransport: 0,
|
|
550
|
+
dshLifecycle: 0,
|
|
551
|
+
centralAnnouncements: 0,
|
|
552
|
+
feedback: 0,
|
|
553
|
+
deviceKeys: 0,
|
|
554
|
+
},
|
|
555
|
+
deviceKeys: { supported: false, enabled: false, entries: [] },
|
|
543
556
|
upstream: { url: 'DSH 内嵌(同进程, 无需网关)', reachable: true },
|
|
544
557
|
latest: { version, newer: false },
|
|
545
558
|
onlineCount: 0,
|
|
546
559
|
deviceCount: 0,
|
|
547
560
|
totalRequests: 0,
|
|
548
561
|
authFailures: 0,
|
|
562
|
+
events: {
|
|
563
|
+
mux: { connected: false, attempt: 0, lastError: '网关未运行' },
|
|
564
|
+
host: { connected: false, attempt: 0, lastError: '网关未运行' },
|
|
565
|
+
},
|
|
549
566
|
devices: [],
|
|
550
567
|
})
|
|
551
568
|
return
|
|
552
569
|
}
|
|
553
|
-
|
|
570
|
+
const gatewayAdminMutations = new Map([
|
|
571
|
+
[`${MOUNT}/admin/api/note`, '/note'],
|
|
572
|
+
[`${MOUNT}/admin/api/kick`, '/kick'],
|
|
573
|
+
[`${MOUNT}/admin/api/token/rotate`, '/token/rotate'],
|
|
574
|
+
[`${MOUNT}/admin/api/device-keys/mode`, '/device-keys/mode'],
|
|
575
|
+
[`${MOUNT}/admin/api/device-keys/create`, '/device-keys/create'],
|
|
576
|
+
[`${MOUNT}/admin/api/device-keys/note`, '/device-keys/note'],
|
|
577
|
+
[`${MOUNT}/admin/api/device-keys/rotate`, '/device-keys/rotate'],
|
|
578
|
+
[`${MOUNT}/admin/api/device-keys/revoke`, '/device-keys/revoke'],
|
|
579
|
+
])
|
|
580
|
+
if (gatewayAdminMutations.has(pathname)) {
|
|
554
581
|
if (req.method !== 'POST') {
|
|
555
582
|
res.writeHead(405, { allow: 'POST' })
|
|
556
583
|
res.end()
|
|
557
584
|
return
|
|
558
585
|
}
|
|
559
586
|
const body = await readBody(req, 4096)
|
|
560
|
-
const sub =
|
|
561
|
-
: pathname.endsWith('/kick') ? '/kick'
|
|
562
|
-
: '/token/rotate'
|
|
587
|
+
const sub = gatewayAdminMutations.get(pathname)
|
|
563
588
|
const proxied = await proxyGateway(`/admin/api${sub}`, 'POST', body)
|
|
564
589
|
if (proxied !== null) {
|
|
565
590
|
sendJson(res, proxied.status, proxied.json)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.14",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|