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.
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +294 -17
- 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 +27 -0
- package/public/app.js +205 -20
- package/public/desktop/desktop.css +5 -0
- package/public/desktop/desktop.html +4 -2
- package/public/desktop/desktop.js +103 -14
- package/public/index.html +29 -4
- package/public/styles.css +23 -0
- package/public/update.json +8 -8
- 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,13 +379,23 @@ 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
|
|
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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=' +
|
|
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=' +
|
|
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
|
-
|
|
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.13",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|