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.
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 = crypto.randomBytes(24).toString('base64url')
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
- if (tokenOf(req, url) === TOKEN) return true
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 issueWsTicket() {
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 clientId = String(extra.clientId || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
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 = String(req.headers['user-agent'] || '')
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 (!authorized(req, url)) {
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 (!authorized(req, url)) {
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 (!authorized(req, url)) {
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
- for (const d of devices.values()) {
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 (!authorized(req, url)) {
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 (!authorized(req, url)) {
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 (!authorized(req, url)) {
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=' + 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=' + 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
- if (pathname === `${MOUNT}/admin/api/note` || pathname === `${MOUNT}/admin/api/kick` || pathname === `${MOUNT}/admin/api/token/rotate`) {
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 = pathname.endsWith('/note') ? '/note'
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.12",
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",