dsh-remote-plugin 0.6.12 → 0.6.13

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,13 +379,23 @@ 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
 
@@ -310,7 +454,7 @@ function touchDevice(req, extra = {}) {
310
454
  if (!d) {
311
455
  d = {
312
456
  id: deviceKey, ip, clientId, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
313
- requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
457
+ credentialId: '', requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
314
458
  }
315
459
  devices.set(deviceKey, d)
316
460
  }
@@ -326,6 +470,7 @@ function touchDevice(req, extra = {}) {
326
470
  d.channels[extra.closeChannel] = count > 0
327
471
  }
328
472
  if (extra.failedAuth) d.authFailures++
473
+ if (req.dshRemoteAuth?.type === 'device') d.credentialId = req.dshRemoteAuth.id
329
474
  const marked = req.headers['x-dsh-remote-client']
330
475
  if (marked) d.kind = marked
331
476
  const ua = String(req.headers['user-agent'] || '')
@@ -339,6 +484,7 @@ function deviceViews() {
339
484
  ip: d.ip,
340
485
  id: d.id,
341
486
  clientId: d.clientId || '',
487
+ credentialId: d.credentialId || '',
342
488
  note: deviceNotes[d.ip] || '',
343
489
  kind: d.kind,
344
490
  ua: d.ua,
@@ -369,6 +515,44 @@ function kickDevice(ip) {
369
515
  return n
370
516
  }
371
517
 
518
+ function kickCredential(credentialId) {
519
+ const targets = [...devices.values()].filter(d => d.credentialId === credentialId)
520
+ let n = 0
521
+ for (const d of targets) {
522
+ for (const sock of d.sockets) {
523
+ try { sock.destroy() } catch {}
524
+ n++
525
+ }
526
+ d.sockets.clear()
527
+ d.channels = {}
528
+ d.channelCounts = {}
529
+ }
530
+ return n
531
+ }
532
+
533
+ function kickRemoteClients() {
534
+ let n = 0
535
+ for (const d of devices.values()) {
536
+ if (d.kind === 'admin') continue
537
+ for (const sock of d.sockets) {
538
+ try { sock.destroy() } catch {}
539
+ n++
540
+ }
541
+ d.sockets.clear()
542
+ d.channels = {}
543
+ d.channelCounts = {}
544
+ }
545
+ return n
546
+ }
547
+
548
+ function deviceKeysPayload() {
549
+ return {
550
+ supported: true,
551
+ enabled: deviceKeyState.enabled,
552
+ entries: deviceKeyViews(),
553
+ }
554
+ }
555
+
372
556
  // ---------- GitHub/镜像 更新检查 ----------
373
557
  function parseVersion(v) {
374
558
  const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
@@ -810,7 +994,7 @@ async function serveDshControl(req, res, url) {
810
994
  res.end()
811
995
  return
812
996
  }
813
- if (!authorized(req, url)) {
997
+ if (!controlAuthorized(req, url)) {
814
998
  authFailures++
815
999
  touchDevice(req, { failedAuth: true })
816
1000
  cors(res)
@@ -998,7 +1182,7 @@ function serveWsTicket(req, res, url) {
998
1182
  }
999
1183
  cors(res, req)
1000
1184
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1001
- res.end(JSON.stringify({ ok: true, ...issueWsTicket() }))
1185
+ res.end(JSON.stringify({ ok: true, ...issueWsTicket(req.dshRemoteAuth) }))
1002
1186
  }
1003
1187
 
1004
1188
  function serveEventPoll(req, res, url) {
@@ -1597,7 +1781,7 @@ function serveAdminApi(req, res, url) {
1597
1781
  const sub = url.pathname.slice('/admin/api'.length) || '/'
1598
1782
  if (sub === '/dsh') return serveDshControl(req, res, url)
1599
1783
  if (sub === '/state' && req.method === 'GET') {
1600
- if (!authorized(req, url)) {
1784
+ if (!adminAuthorized(req, url)) {
1601
1785
  authFailures++
1602
1786
  touchDevice(req, { failedAuth: true })
1603
1787
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1611,12 +1795,15 @@ function serveAdminApi(req, res, url) {
1611
1795
  mode: 'gateway',
1612
1796
  version: VERSION,
1613
1797
  pid: process.pid,
1798
+ platform: process.platform,
1614
1799
  hostname: os.hostname(),
1615
1800
  lanIPs: lanAddresses(),
1616
1801
  startedAt: STARTED_AT,
1617
1802
  uptimeSec: Math.round((Date.now() - STARTED_AT) / 1000),
1618
1803
  host: HOST,
1619
1804
  port: PORT,
1805
+ protocol: { version: PROTOCOL_VERSION },
1806
+ capabilities: CAPABILITIES,
1620
1807
  upstream: { url: UPSTREAM.origin, reachable },
1621
1808
  latest: {
1622
1809
  version: latestState.version,
@@ -1630,17 +1817,106 @@ function serveAdminApi(req, res, url) {
1630
1817
  tokenFromEnv: TOKEN_FROM_ENV,
1631
1818
  tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
1632
1819
  tokenLength: TOKEN.length,
1820
+ deviceKeys: deviceKeysPayload(),
1633
1821
  totalRequests,
1634
1822
  authFailures,
1635
1823
  deviceCount: devices.size,
1636
1824
  onlineCount: [...devices.values()].filter(d => Date.now() - d.lastSeen < 60_000).length,
1825
+ events: eventCollectorState,
1637
1826
  devices: deviceViews()
1638
1827
  }))
1639
1828
  })
1640
1829
  return
1641
1830
  }
1831
+ if (sub.startsWith('/device-keys/') && req.method === 'POST') {
1832
+ if (!adminAuthorized(req, url)) {
1833
+ authFailures++
1834
+ touchDevice(req, { failedAuth: true })
1835
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1836
+ res.end(JSON.stringify({ error: 'unauthorized' }))
1837
+ return
1838
+ }
1839
+ let body = ''
1840
+ req.on('data', chunk => {
1841
+ body += chunk
1842
+ if (body.length > 8192) req.destroy()
1843
+ })
1844
+ req.on('end', () => {
1845
+ let payload = {}
1846
+ try { payload = JSON.parse(body || '{}') } catch {
1847
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1848
+ res.end(JSON.stringify({ error: 'bad-request', detail: '请求内容不是有效 JSON' }))
1849
+ return
1850
+ }
1851
+ const send = (status, value) => {
1852
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1853
+ res.end(JSON.stringify(value))
1854
+ }
1855
+ if (sub === '/device-keys/mode') {
1856
+ if (typeof payload.enabled !== 'boolean') return send(400, { error: 'bad-request', detail: 'enabled 必须是布尔值' })
1857
+ if (payload.enabled && deviceKeyState.keys.length === 0 && !createDeviceKey(payload.note || '我的设备')) {
1858
+ return send(500, { error: 'write-failed', detail: '无法创建首个设备密钥' })
1859
+ }
1860
+ const previous = deviceKeyState.enabled
1861
+ deviceKeyState.enabled = payload.enabled
1862
+ if (!saveDeviceKeys()) {
1863
+ deviceKeyState.enabled = previous
1864
+ return send(500, { error: 'write-failed', detail: '无法保存独立设备密钥设置' })
1865
+ }
1866
+ wsTickets.clear()
1867
+ const disconnected = kickRemoteClients()
1868
+ return send(200, { ok: true, disconnected, deviceKeys: deviceKeysPayload() })
1869
+ }
1870
+ if (sub === '/device-keys/create') {
1871
+ if (deviceKeyState.keys.length >= 100) return send(409, { error: 'too-many-device-keys', detail: '设备密钥数量已达到上限' })
1872
+ const record = createDeviceKey(payload.note)
1873
+ return record
1874
+ ? send(201, { ok: true, entry: { ...record }, deviceKeys: deviceKeysPayload() })
1875
+ : send(500, { error: 'write-failed', detail: '无法保存设备密钥' })
1876
+ }
1877
+ const id = String(payload.id || '').trim()
1878
+ const index = deviceKeyState.keys.findIndex(record => record.id === id)
1879
+ if (index < 0) return send(404, { error: 'device-key-not-found', detail: '找不到该设备密钥' })
1880
+ const record = deviceKeyState.keys[index]
1881
+ if (sub === '/device-keys/note') {
1882
+ const previous = { note: record.note, updatedAt: record.updatedAt }
1883
+ record.note = String(payload.note || '').trim().slice(0, 40)
1884
+ record.updatedAt = Date.now()
1885
+ if (!saveDeviceKeys()) {
1886
+ Object.assign(record, previous)
1887
+ return send(500, { error: 'write-failed', detail: '无法保存备注' })
1888
+ }
1889
+ return send(200, { ok: true, entry: { ...record } })
1890
+ }
1891
+ if (sub === '/device-keys/rotate') {
1892
+ const previous = { token: record.token, updatedAt: record.updatedAt, lastUsedAt: record.lastUsedAt }
1893
+ record.token = newAccessToken()
1894
+ record.updatedAt = Date.now()
1895
+ record.lastUsedAt = 0
1896
+ if (!saveDeviceKeys()) {
1897
+ Object.assign(record, previous)
1898
+ return send(500, { error: 'write-failed', detail: '无法轮换设备令牌' })
1899
+ }
1900
+ wsTickets.clear()
1901
+ const disconnected = kickCredential(record.id)
1902
+ return send(200, { ok: true, disconnected, entry: { ...record } })
1903
+ }
1904
+ if (sub === '/device-keys/revoke') {
1905
+ deviceKeyState.keys.splice(index, 1)
1906
+ if (!saveDeviceKeys()) {
1907
+ deviceKeyState.keys.splice(index, 0, record)
1908
+ return send(500, { error: 'write-failed', detail: '无法退出设备' })
1909
+ }
1910
+ wsTickets.clear()
1911
+ const disconnected = kickCredential(record.id)
1912
+ return send(200, { ok: true, disconnected, id: record.id })
1913
+ }
1914
+ return send(404, { error: 'not-found' })
1915
+ })
1916
+ return
1917
+ }
1642
1918
  if (sub === '/token/rotate' && req.method === 'POST') {
1643
- if (!authorized(req, url)) {
1919
+ if (!adminAuthorized(req, url)) {
1644
1920
  authFailures++
1645
1921
  touchDevice(req, { failedAuth: true })
1646
1922
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1654,16 +1930,14 @@ function serveAdminApi(req, res, url) {
1654
1930
  return
1655
1931
  }
1656
1932
  // 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
1657
- for (const d of devices.values()) {
1658
- if (d.kind !== 'admin') kickDevice(d.ip)
1659
- }
1933
+ kickRemoteClients()
1660
1934
  touchDevice(req)
1661
1935
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1662
1936
  res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
1663
1937
  return
1664
1938
  }
1665
1939
  if (sub === '/shutdown' && req.method === 'POST') {
1666
- if (!authorized(req, url)) {
1940
+ if (!adminAuthorized(req, url)) {
1667
1941
  authFailures++
1668
1942
  touchDevice(req, { failedAuth: true })
1669
1943
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1680,7 +1954,7 @@ function serveAdminApi(req, res, url) {
1680
1954
  return
1681
1955
  }
1682
1956
  if (sub === '/note' && req.method === 'POST') {
1683
- if (!authorized(req, url)) {
1957
+ if (!adminAuthorized(req, url)) {
1684
1958
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1685
1959
  res.end(JSON.stringify({ error: 'unauthorized' }))
1686
1960
  return
@@ -1705,7 +1979,7 @@ function serveAdminApi(req, res, url) {
1705
1979
  return
1706
1980
  }
1707
1981
  if (sub === '/kick' && req.method === 'POST') {
1708
- if (!authorized(req, url)) {
1982
+ if (!adminAuthorized(req, url)) {
1709
1983
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1710
1984
  res.end(JSON.stringify({ error: 'unauthorized' }))
1711
1985
  return
@@ -2694,6 +2968,8 @@ async function serveHealth(res) {
2694
2968
  ok: true,
2695
2969
  service: 'dsh-remote',
2696
2970
  version: VERSION,
2971
+ protocol: { version: PROTOCOL_VERSION },
2972
+ capabilities: CAPABILITIES,
2697
2973
  pid: process.pid,
2698
2974
  upstream: UPSTREAM.origin,
2699
2975
  upstreamProbe: DSH_HEALTH_PATH,
@@ -3041,10 +3317,11 @@ server.on('clientError', (err, socket) => {
3041
3317
  })
3042
3318
 
3043
3319
  server.listen(PORT, HOST, () => {
3320
+ const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
3044
3321
  console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
3045
- console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + TOKEN)
3322
+ console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
3046
3323
  for (const ip of lanAddresses()) {
3047
- console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + TOKEN)
3324
+ console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
3048
3325
  }
3049
3326
  console.log(' 管理页: http://127.0.0.1:' + PORT + '/admin')
3050
3327
  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.13",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",