dsh-remote-plugin 0.5.9 → 0.6.1

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
@@ -182,9 +182,18 @@ function authorized(req, url) {
182
182
 
183
183
  // ---------- 设备监控 ----------
184
184
  const devices = new Map() // ip -> device
185
+ // 设备 TTL 是“记录保留时间”,和下方 online 判断的 60s 活跃窗口是两回事:
186
+ // online 只看最近 60s 是否有请求;TTL 用于防止长期运行的网关内存/响应无限膨胀。
187
+ const DEVICE_TTL_MS = 24 * 60 * 60 * 1000
185
188
  let totalRequests = 0
186
189
  let authFailures = 0
187
190
 
191
+ function pruneDevices(now = Date.now()) {
192
+ for (const [ip, d] of devices) {
193
+ if (now - d.lastSeen > DEVICE_TTL_MS) devices.delete(ip)
194
+ }
195
+ }
196
+
188
197
  function loadNotes() {
189
198
  try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')) } catch { return {} }
190
199
  }
@@ -211,6 +220,7 @@ function kindOf(req) {
211
220
  }
212
221
 
213
222
  function touchDevice(req, extra = {}) {
223
+ pruneDevices()
214
224
  const ip = ipOf(req)
215
225
  totalRequests++
216
226
  let d = devices.get(ip)
@@ -402,6 +412,120 @@ function cors(res) {
402
412
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
403
413
  }
404
414
 
415
+ // ---------- 事件轮询缓冲 ----------
416
+ // 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
417
+ // 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
418
+ const EVENT_BUFFER_MAX = 300
419
+ const EVENT_MAX_STRING = 16 * 1024
420
+ const eventBuffers = { mux: [], host: [] }
421
+ const eventNextSeq = { mux: 1, host: 1 }
422
+
423
+ /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
424
+ function truncateEventValue(v, depth = 0) {
425
+ if (typeof v === 'string') return v.length > EVENT_MAX_STRING ? v.slice(0, EVENT_MAX_STRING) + '…[truncated]' : v
426
+ if (Array.isArray(v)) {
427
+ if (depth > 3 || v.length > 200) return v.slice(0, 200)
428
+ return v.map(x => truncateEventValue(x, depth + 1))
429
+ }
430
+ if (v && typeof v === 'object' && depth <= 3) {
431
+ const out = {}
432
+ for (const k of Object.keys(v)) out[k] = truncateEventValue(v[k], depth + 1)
433
+ return out
434
+ }
435
+ return v
436
+ }
437
+
438
+ function pushEvent(kind, full) {
439
+ if (!eventBuffers[kind] || !full || typeof full !== 'object') return
440
+ const buf = eventBuffers[kind]
441
+ buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
442
+ if (buf.length > EVENT_BUFFER_MAX) buf.shift()
443
+ }
444
+
445
+ function serveEventPoll(req, res, url) {
446
+ if (req.method !== 'GET') {
447
+ res.writeHead(405, { allow: 'GET' })
448
+ res.end()
449
+ return
450
+ }
451
+ if (!authorized(req, url)) {
452
+ authFailures++
453
+ touchDevice(req, { failedAuth: true })
454
+ cors(res)
455
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
456
+ res.end(JSON.stringify({ error: 'unauthorized' }))
457
+ return
458
+ }
459
+ touchDevice(req)
460
+ const kind = url.searchParams.get('kind')
461
+ if (kind !== 'mux' && kind !== 'host') {
462
+ cors(res)
463
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
464
+ res.end(JSON.stringify({ error: 'bad-kind', detail: 'kind 必须是 mux 或 host' }))
465
+ return
466
+ }
467
+ const sinceRaw = url.searchParams.get('since')
468
+ const since = sinceRaw === null ? 0 : Number(sinceRaw)
469
+ if (!Number.isSafeInteger(since) || since < 0) {
470
+ cors(res)
471
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
472
+ res.end(JSON.stringify({ error: 'bad-since', detail: 'since 必须是非负整数' }))
473
+ return
474
+ }
475
+ const buf = eventBuffers[kind]
476
+ const events = buf.filter(r => r.seq > since)
477
+ const latestSeq = buf.length ? buf[buf.length - 1].seq : 0
478
+ const truncated = buf.length > 0 && since < buf[0].seq - 1
479
+ cors(res)
480
+ res.writeHead(200, {
481
+ 'content-type': 'application/json; charset=utf-8',
482
+ 'cache-control': 'no-store'
483
+ })
484
+ res.end(JSON.stringify({ ok: true, kind, since, latestSeq, truncated, events }))
485
+ }
486
+
487
+ /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
488
+ function startEventCollector(kind) {
489
+ if (typeof WebSocket !== 'function') return null
490
+ let ws = null
491
+ let stopped = false
492
+ let retryTimer = null
493
+ const url = `ws://${UPSTREAM.hostname}:${UPSTREAM.port}/api/events.${kind}?client=web`
494
+ const connect = () => {
495
+ if (stopped) return
496
+ try {
497
+ ws = new WebSocket(url)
498
+ } catch {
499
+ retryTimer = setTimeout(connect, 3000)
500
+ return
501
+ }
502
+ ws.onopen = () => {
503
+ if (stopped) { try { ws.close() } catch {} }
504
+ }
505
+ ws.onmessage = (ev) => {
506
+ if (stopped) return
507
+ try {
508
+ const data = typeof ev.data === 'string' ? ev.data : Buffer.isBuffer(ev.data) ? ev.data.toString() : String(ev.data)
509
+ pushEvent(kind, JSON.parse(data))
510
+ } catch {}
511
+ }
512
+ ws.onclose = () => {
513
+ ws = null
514
+ if (!stopped) retryTimer = setTimeout(connect, 3000)
515
+ }
516
+ ws.onerror = () => { try { ws.close() } catch {} }
517
+ }
518
+ connect()
519
+ return {
520
+ kind,
521
+ close() {
522
+ stopped = true
523
+ clearTimeout(retryTimer)
524
+ try { ws?.close() } catch {}
525
+ }
526
+ }
527
+ }
528
+
405
529
  // ---------- 统计 API ----------
406
530
  let statsScanning = false
407
531
  async function scanStatsOnce(delay) {
@@ -1498,6 +1622,10 @@ function proxyApi(req, res, url) {
1498
1622
  headers[k] = v
1499
1623
  }
1500
1624
  headers.host = UPSTREAM.host
1625
+ // /remote/* 由 DSH 插件端点处理;插件侧用网关自身 token 鉴权。
1626
+ if (url.pathname.startsWith('/remote/')) {
1627
+ headers.authorization = 'Bearer ' + TOKEN
1628
+ }
1501
1629
 
1502
1630
  const upstreamReq = http.request({
1503
1631
  hostname: UPSTREAM.hostname,
@@ -1548,6 +1676,8 @@ const server = http.createServer((req, res) => {
1548
1676
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
1549
1677
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
1550
1678
  if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
1679
+ if (url.pathname === '/api/events.poll') return serveEventPoll(req, res, url)
1680
+ if (url.pathname.startsWith('/remote/')) return proxyApi(req, res, url)
1551
1681
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
1552
1682
  if (url.pathname === '/health') return serveHealth(res)
1553
1683
  touchDevice(req)
@@ -1669,6 +1799,9 @@ server.listen(PORT, HOST, () => {
1669
1799
  console.log(' 提示: 监听在 127.0.0.1, 手机请改用 Tailscale serve 或设置 HOST=0.0.0.0')
1670
1800
  }
1671
1801
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
1802
+ // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
1803
+ startEventCollector('mux')
1804
+ startEventCollector('host')
1672
1805
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
1673
1806
  setTimeout(() => checkForUpdates(false), 8000)
1674
1807
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
package/index.mjs CHANGED
@@ -13,7 +13,7 @@ import { dirname, extname, normalize, resolve } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
14
 
15
15
  export const name = 'dsh-remote'
16
- export const inject = ['webServer']
16
+ export const inject = ['webServer', 'commands', 'agents']
17
17
 
18
18
  const MOUNT = '/remote'
19
19
  const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
@@ -284,6 +284,25 @@ function statsSend(session, event) {
284
284
  statsQueues.set(session.id, next)
285
285
  }
286
286
 
287
+ /** 从 sessionId 解析 DSH Agent:优先取已发布 live agent,否则走 resume。返回 { agent, resolvePath } 便于定位。 */
288
+ async function resolveAgent(ctx, sessionId) {
289
+ if (!ctx.agents) return { agent: null, resolvePath: 'no-agents-service' }
290
+ let live
291
+ try {
292
+ live = ctx.agents.get(sessionId)
293
+ } catch (e) {
294
+ live = undefined
295
+ }
296
+ if (live) return { agent: live, resolvePath: 'live' }
297
+ try {
298
+ const handle = await ctx.agents.resume({ resumeSessionId: sessionId })
299
+ if (!handle || !handle.agent) return { agent: null, resolvePath: 'resume-empty-handle' }
300
+ return { agent: handle.agent, resolvePath: 'resume' }
301
+ } catch (e) {
302
+ return { agent: null, resolvePath: 'resume-error: ' + (e?.message || String(e)) }
303
+ }
304
+ }
305
+
287
306
  async function resolveFile(pathname) {
288
307
  let abs = targetPath(pathname)
289
308
  if (abs === null) return null
@@ -313,7 +332,7 @@ async function resolveFile(pathname) {
313
332
  }
314
333
  }
315
334
 
316
- async function serveStatic(req, res) {
335
+ async function serveStatic(req, res, ctx) {
317
336
  const pathname = new URL(req.url ?? '/', 'http://x').pathname
318
337
 
319
338
  // 无尾斜杠的入口重定向到带斜杠版本:
@@ -417,6 +436,52 @@ async function serveStatic(req, res) {
417
436
  return
418
437
  }
419
438
 
439
+ // 斜杠命令桥接:客户端 → 网关 → 插件端点 → ctx.commands.execute
440
+ if (pathname === `${MOUNT}/api/command`) {
441
+ if (req.method !== 'POST') {
442
+ res.writeHead(405, { allow: 'POST' })
443
+ res.end()
444
+ return
445
+ }
446
+ const auth = req.headers.authorization || ''
447
+ const expected = gatewayToken()
448
+ if (!expected || auth !== `Bearer ${expected}`) {
449
+ sendJson(res, 401, { ok: false, message: 'unauthorized' })
450
+ return
451
+ }
452
+ let body
453
+ try {
454
+ body = JSON.parse((await readBody(req, 8192)) || '{}')
455
+ } catch {
456
+ sendJson(res, 400, { ok: false, message: 'invalid json' })
457
+ return
458
+ }
459
+ const { sessionId, line } = body || {}
460
+ if (!sessionId || typeof line !== 'string') {
461
+ sendJson(res, 400, { ok: false, message: 'sessionId and line required' })
462
+ return
463
+ }
464
+ try {
465
+ const { agent, resolvePath } = await resolveAgent(ctx, sessionId)
466
+ if (!agent) {
467
+ sendJson(res, 200, { ok: false, message: 'agent not found', debug: { resolvePath, commandNames: [] } })
468
+ return
469
+ }
470
+ let commandNames = []
471
+ try {
472
+ commandNames = (await ctx.commands.list(agent)).map(c => c.name)
473
+ } catch (e) {
474
+ commandNames = ['list-error: ' + (e?.message || String(e))]
475
+ }
476
+ const signal = AbortSignal.timeout(30000)
477
+ const result = await ctx.commands.execute(agent, line, signal)
478
+ sendJson(res, 200, { ok: true, executed: result !== undefined, debug: { resolvePath, commandNames } })
479
+ } catch (e) {
480
+ sendJson(res, 200, { ok: false, message: e?.message || String(e) })
481
+ }
482
+ return
483
+ }
484
+
420
485
  if (req.method !== 'GET' && req.method !== 'HEAD') {
421
486
  res.writeHead(405, { allow: 'GET, HEAD' })
422
487
  res.end()
@@ -446,7 +511,7 @@ export function apply(ctx) {
446
511
  ctx.effect(() => ctx.webServer.register({
447
512
  kind: 'prefix',
448
513
  path: MOUNT,
449
- handler: serveStatic,
514
+ handler: (req, res) => serveStatic(req, res, ctx),
450
515
  }), 'dsh-remote: /remote route')
451
516
  // 实时统计: 监听 DSH 会话事件流, 把带 usage 的 assistant/message 投递到网关聚合
452
517
  ctx.on('session/event', (session, event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.9",
3
+ "version": "0.6.1",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/public/admin.html CHANGED
@@ -184,6 +184,7 @@
184
184
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8a8.01 8.01 0 0 0 5.47 7.59c.4.08.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
185
185
  <span class="gh-label" data-i18n="repo">仓库</span>
186
186
  </a>
187
+ <button id="btn-donate" class="mini-btn tb-btn" data-i18n-title="donateTitle">🎁</button>
187
188
  <div class="fb-wrap">
188
189
  <button id="btn-feedback" class="mini-btn fb-btn tb-btn" data-i18n-title="feedbackTitle" data-i18n-aria="feedbackTitle" aria-haspopup="menu" aria-expanded="false">
189
190
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-8 8H5l-1.5 2L4 20a8 8 0 0 1-1-4 8 8 0 0 1 18-4Z"/></svg>
@@ -271,6 +272,20 @@
271
272
  </div>
272
273
  </div>
273
274
 
275
+ <!-- 赞赏支持模态 -->
276
+ <div id="modal-donate" class="modal hidden">
277
+ <div class="modal-card">
278
+ <div class="modal-title" data-i18n="donateTitle">赞赏支持</div>
279
+ <div class="modal-body donate-body">
280
+ <img src="donate.png" alt="" class="donate-img" style="display:block;margin:0 auto;max-width:100%;max-height:60vh;border-radius:8px">
281
+ <p class="donate-thanks" data-i18n="donateThanks" style="text-align:center;margin-top:12px">感谢你的支持 ☕</p>
282
+ </div>
283
+ <div class="modal-actions">
284
+ <button id="donate-close" class="btn subtle" data-i18n="theme.close">关闭</button>
285
+ </div>
286
+ </div>
287
+ </div>
288
+
274
289
  <div id="toast" class="toast hidden"></div>
275
290
  <script>
276
291
  window.ADMIN_STR = {
@@ -282,6 +297,7 @@
282
297
  'theme.default': '默认深空', 'theme.dark': '落日', 'theme.light': '易北爱乐厅', 'theme.neutral': '草原孤塔',
283
298
  'theme.panelTitle': '选择配色', 'theme.close': '关闭',
284
299
  'repo': '仓库',
300
+ 'donateTitle': '赞赏支持', 'donateThanks': '感谢你的支持 ☕',
285
301
  'feedbackTitle': '反馈渠道',
286
302
  'feedback.githubDesc': '反馈 bug / 提建议', 'feedback.giteeDesc': '国内镜像,无需代理', 'feedback.biliDesc': 'UP 动态页交流',
287
303
  'unauth': '未认证',
@@ -356,6 +372,7 @@
356
372
  'theme.default': 'Default', 'theme.dark': 'Sunset', 'theme.light': 'Elbphilharmonie', 'theme.neutral': 'Prairie Tower',
357
373
  'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
358
374
  'repo': 'Repo',
375
+ 'donateTitle': 'Support', 'donateThanks': 'Thanks for your support ☕',
359
376
  'feedbackTitle': 'Feedback',
360
377
  'feedback.githubDesc': 'Report bugs · suggest features', 'feedback.giteeDesc': 'Mirror in China, no proxy needed', 'feedback.biliDesc': 'Chat on the UP\'s Bilibili page',
361
378
  'unauth': 'Not connected',
package/public/admin.js CHANGED
@@ -482,6 +482,11 @@ function openThemePanel() {
482
482
  $('modal-theme').classList.remove('hidden')
483
483
  }
484
484
 
485
+ function openDonateModal() {
486
+ const m = $('modal-donate')
487
+ if (m) m.classList.remove('hidden')
488
+ }
489
+
485
490
  $('btn-lang').addEventListener('click', () => {
486
491
  I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
487
492
  renderLangBtn()
@@ -492,6 +497,10 @@ $('btn-lang').addEventListener('click', () => {
492
497
 
493
498
  $('btn-theme').addEventListener('click', openThemePanel)
494
499
  $('theme-close').addEventListener('click', () => $('modal-theme').classList.add('hidden'))
500
+ // 赞赏支持
501
+ $('btn-donate').addEventListener('click', openDonateModal)
502
+ $('donate-close').addEventListener('click', () => $('modal-donate').classList.add('hidden'))
503
+ $('modal-donate').addEventListener('click', (e) => { if (e.target === $('modal-donate')) $('modal-donate').classList.add('hidden') })
495
504
  // 反馈
496
505
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
497
506
  $('fb-menu').addEventListener('click', (e) => {