dsh-remote-plugin 0.6.4 → 0.6.6

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/README.en.md CHANGED
@@ -28,7 +28,7 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
28
28
  - On/off intent persists in `~/.dsh-remote/gateway.enabled`; it can be stopped/started from the drawer.
29
29
  - Token lives in `~/.dsh-remote/token` (auto-generated on first run, reused and never overwritten), shown in the drawer and copyable; supports **QR pairing** and **one-click rotation**.
30
30
  - Env var `DSH_REMOTE_AUTOSTART=0` disables auto management.
31
- - File endpoints: `/fs/list` (list directory), `/fs/file` (download with Range support), `/fs/upload` (chunked resume with pause/cancel, SHA-256 verified before writing to disk); default root is `~`, and `DSH_REMOTE_FS_ROOT` opens multiple roots (`:`-separated).
31
+ - File endpoints: `/fs/list` (list directory), `/fs/file` (download with Range support), `/fs/upload` (chunked resume with pause/cancel, SHA-256 verified before writing to disk); default root is `~`, and `DSH_REMOTE_FS_ROOT` opens multiple roots (`:` on Linux/macOS, `;` on Windows).
32
32
  - Feedback endpoint: `POST /feedback` (the app / desktop "Write feedback" dialog), forwarded by the gateway to the feedback collector; default `http://100.84.128.29/submit` (Tailscale internal network), overridable via `DSH_REMOTE_FEEDBACK_URL` — no tokens to configure.
33
33
 
34
34
  ## Mobile App
package/README.md CHANGED
@@ -28,7 +28,7 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
28
28
  - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
29
29
  - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
30
30
  - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
31
- - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(`:` 分隔)。
31
+ - 文件端点:`/fs/list`(列目录)、`/fs/file`(下载,支持 Range)、`/fs/upload`(分块续传,支持暂停/取消,落盘前 SHA-256 校验);默认根目录 `~`,`DSH_REMOTE_FS_ROOT` 可开多根(Linux/macOS 用 `:`,Windows 用 `;` 分隔)。
32
32
  - 反馈端点:`POST /feedback`(App / 桌面端「写反馈」),网关转发到反馈收集器;默认 `http://100.84.128.29/submit`(Tailscale 内网),可用 `DSH_REMOTE_FEEDBACK_URL` 覆盖,无需配置任何 token。
33
33
 
34
34
  ## 手机 App
Binary file
package/gateway-stats.cjs CHANGED
@@ -247,7 +247,11 @@ class StatsStore {
247
247
 
248
248
  const model = eventModel(event) || fallbackModel || 'unknown'
249
249
  const { date, hour, period } = eventKey(time)
250
- if (date < PRICING_START_DATE) return { processed: false, gap: false, skip: true }
250
+ if (date < PRICING_START_DATE) {
251
+ // 定价生效日前的事件不计费,但必须推进游标;否则生效日后的第一条事件会被误判为 gap。
252
+ this._setCursor(sessionId, seq)
253
+ return { processed: false, gap: false, skip: true }
254
+ }
251
255
  const day = this._loadDay(date)
252
256
  const hourBucket = day.hours[hour] || (day.hours[hour] = {})
253
257
  const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
@@ -267,10 +271,13 @@ class StatsStore {
267
271
  * 用系统 zstd 命令解压(项目约束: 不新增 npm 运行时依赖; Windows 无 zstd 时跳过)。
268
272
  */
269
273
  scanFile(file, onProgress) {
274
+ return this._enqueue(() => this._scanFile(file, onProgress))
275
+ }
276
+
277
+ _scanFile(file, onProgress) {
270
278
  return new Promise((resolvePromise) => {
271
279
  const sessionId = path.basename(path.dirname(file))
272
280
  const cur = this._cursor(sessionId)
273
- const startSeq = cur ? cur.lastSeq + 1 : 0
274
281
  let lastSeq = cur ? cur.lastSeq : -1
275
282
  let processed = 0
276
283
  let currentModel = ''
package/gateway.cjs CHANGED
@@ -18,13 +18,14 @@
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_FS_ROOT 文件传输允许根, 默认 ~, 多个用 ':' 分隔
21
+ * DSH_REMOTE_FS_ROOT 文件传输允许根, 默认 ~, 使用系统路径分隔符配置多根
22
22
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
23
23
  */
24
24
  'use strict'
25
25
 
26
26
  const http = require('node:http')
27
27
  const https = require('node:https')
28
+ const { execFile } = require('node:child_process')
28
29
  const fs = require('node:fs')
29
30
  const path = require('node:path')
30
31
  const os = require('node:os')
@@ -43,10 +44,12 @@ const ROOT = __dirname
43
44
  const PUBLIC_DIR = path.join(ROOT, 'public')
44
45
  const PORT = Number(process.env.PORT) || 8787
45
46
  const HOST = process.env.HOST || '0.0.0.0'
47
+ const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
46
48
  const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
47
49
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
48
50
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
49
51
  const STARTED_AT = Date.now()
52
+ const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
50
53
 
51
54
  // 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
52
55
  const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
@@ -81,12 +84,13 @@ const MIME = {
81
84
  }
82
85
 
83
86
  // ---------- /fs 文件传输 ----------
84
- // 允许访问的根目录: DSH_REMOTE_FS_ROOT 用 ':' 分隔多个根, 默认仅 ~。
87
+ // 允许访问的根目录: DSH_REMOTE_FS_ROOT 使用系统路径分隔符分隔多个根,
88
+ // POSIX 为 ':'、Windows 为 ';';默认仅 ~。
85
89
  // 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
86
90
  // 复核一次, 防止 ../ 穿越与符号链接逃逸。
87
91
  const FS_DEFAULT_ROOT = path.resolve(os.homedir())
88
92
  const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
89
- .split(':')
93
+ .split(path.delimiter)
90
94
  .filter(Boolean)
91
95
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
92
96
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
@@ -306,7 +310,9 @@ function httpGetJson(url, cb) {
306
310
  const isHttps = u.protocol === 'https:'
307
311
  const lib = isHttps ? https : http
308
312
  const proxyEnv = process.env.UPDATE_PROXY ||
309
- (isHttps ? process.env.HTTPS_PROXY : process.env.HTTP_PROXY) || ''
313
+ (isHttps
314
+ ? (process.env.HTTPS_PROXY || process.env.https_proxy)
315
+ : (process.env.HTTP_PROXY || process.env.http_proxy)) || ''
310
316
  const done = (err, value) => { if (settled) return; settled = true; cb(err, value) }
311
317
  let settled = false
312
318
  const timer = setTimeout(() => done(new Error('检查超时')), 6000)
@@ -412,6 +418,109 @@ function cors(res) {
412
418
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
413
419
  }
414
420
 
421
+ function readBody(req, maxBytes = 64 * 1024) {
422
+ return new Promise((resolve, reject) => {
423
+ const chunks = []
424
+ let size = 0
425
+ let settled = false
426
+ req.on('data', chunk => {
427
+ if (settled) return
428
+ size += chunk.length
429
+ if (size > maxBytes) {
430
+ settled = true
431
+ reject(new Error('request body too large'))
432
+ req.destroy()
433
+ return
434
+ }
435
+ chunks.push(chunk)
436
+ })
437
+ req.on('end', () => {
438
+ if (settled) return
439
+ settled = true
440
+ resolve(Buffer.concat(chunks).toString('utf8'))
441
+ })
442
+ req.on('error', err => {
443
+ if (settled) return
444
+ settled = true
445
+ reject(err)
446
+ })
447
+ })
448
+ }
449
+
450
+ function execFileResult(file, args, timeout = 5000) {
451
+ return new Promise((resolvePromise) => {
452
+ execFile(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
453
+ resolvePromise({
454
+ ok: !error,
455
+ code: error?.code ?? 0,
456
+ stdout: String(stdout || '').trim(),
457
+ stderr: String(stderr || '').trim(),
458
+ })
459
+ })
460
+ })
461
+ }
462
+
463
+ async function dshServiceStatus() {
464
+ if (process.platform === 'win32') {
465
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows 请配置 DSH_REMOTE_DSH_SERVICE 后接入任务计划程序' }
466
+ }
467
+ if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
468
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, message: '服务名配置不合法' }
469
+ }
470
+ const r = await execFileResult('systemctl', ['--user', 'is-active', DSH_SERVICE], 3000)
471
+ return { ok: true, supported: true, running: r.stdout === 'active', service: DSH_SERVICE, state: r.stdout || 'unknown', detail: r.stderr || '' }
472
+ }
473
+
474
+ async function serveDshControl(req, res, url) {
475
+ if (req.method === 'OPTIONS') {
476
+ cors(res)
477
+ res.writeHead(204)
478
+ res.end()
479
+ return
480
+ }
481
+ if (!authorized(req, url)) {
482
+ authFailures++
483
+ touchDevice(req, { failedAuth: true })
484
+ cors(res)
485
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
486
+ res.end(JSON.stringify({ error: 'unauthorized' }))
487
+ return
488
+ }
489
+ touchDevice(req, { kind: 'admin' })
490
+ if (req.method === 'GET') {
491
+ cors(res)
492
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
493
+ res.end(JSON.stringify(await dshServiceStatus()))
494
+ return
495
+ }
496
+ if (req.method !== 'POST') {
497
+ cors(res)
498
+ res.writeHead(405, { allow: 'GET, POST' })
499
+ res.end()
500
+ return
501
+ }
502
+ let body = {}
503
+ try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch {}
504
+ const action = body?.action
505
+ if (action !== 'start' && action !== 'restart') {
506
+ cors(res)
507
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
508
+ res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
509
+ return
510
+ }
511
+ if (process.platform === 'win32' || !/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
512
+ cors(res)
513
+ res.writeHead(501, { 'content-type': 'application/json; charset=utf-8' })
514
+ res.end(JSON.stringify({ ok: false, supported: false, error: '当前系统未配置可控的 dsh-web 服务', service: DSH_SERVICE }))
515
+ return
516
+ }
517
+ const r = await execFileResult('systemctl', ['--user', action, DSH_SERVICE], 10000)
518
+ const status = await dshServiceStatus()
519
+ cors(res)
520
+ res.writeHead(r.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
521
+ res.end(JSON.stringify({ ...status, ok: r.ok, action, detail: r.stderr || r.stdout || '' }))
522
+ }
523
+
415
524
  // ---------- 事件轮询缓冲 ----------
416
525
  // 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
417
526
  // 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
@@ -419,6 +528,10 @@ const EVENT_BUFFER_MAX = 300
419
528
  const EVENT_MAX_STRING = 16 * 1024
420
529
  const eventBuffers = { mux: [], host: [] }
421
530
  const eventNextSeq = { mux: 1, host: 1 }
531
+ const eventCollectorState = {
532
+ mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
533
+ host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
534
+ }
422
535
 
423
536
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
424
537
  function truncateEventValue(v, depth = 0) {
@@ -437,6 +550,7 @@ function truncateEventValue(v, depth = 0) {
437
550
 
438
551
  function pushEvent(kind, full) {
439
552
  if (!eventBuffers[kind] || !full || typeof full !== 'object') return
553
+ if (eventCollectorState[kind]) eventCollectorState[kind].lastEventAt = Date.now()
440
554
  const buf = eventBuffers[kind]
441
555
  buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
442
556
  if (buf.length > EVENT_BUFFER_MAX) buf.shift()
@@ -487,6 +601,7 @@ function serveEventPoll(req, res, url) {
487
601
  /** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
488
602
  function startEventCollector(kind) {
489
603
  if (typeof WebSocket !== 'function') return null
604
+ const state = eventCollectorState[kind]
490
605
  let ws = null
491
606
  let stopped = false
492
607
  let retryTimer = null
@@ -500,6 +615,11 @@ function startEventCollector(kind) {
500
615
  return
501
616
  }
502
617
  ws.onopen = () => {
618
+ if (state) {
619
+ state.connected = true
620
+ state.lastConnectAt = Date.now()
621
+ state.lastError = ''
622
+ }
503
623
  if (stopped) { try { ws.close() } catch {} }
504
624
  }
505
625
  ws.onmessage = (ev) => {
@@ -510,10 +630,17 @@ function startEventCollector(kind) {
510
630
  } catch {}
511
631
  }
512
632
  ws.onclose = () => {
633
+ if (state) {
634
+ state.connected = false
635
+ state.reconnects++
636
+ }
513
637
  ws = null
514
638
  if (!stopped) retryTimer = setTimeout(connect, 3000)
515
639
  }
516
- ws.onerror = () => { try { ws.close() } catch {} }
640
+ ws.onerror = (err) => {
641
+ if (state) state.lastError = String(err?.message || 'websocket error')
642
+ try { ws.close() } catch {}
643
+ }
517
644
  }
518
645
  connect()
519
646
  return {
@@ -817,6 +944,7 @@ function upstreamReachable(cb) {
817
944
 
818
945
  function serveAdminApi(req, res, url) {
819
946
  const sub = url.pathname.slice('/admin/api'.length) || '/'
947
+ if (sub === '/dsh') return serveDshControl(req, res, url)
820
948
  if (sub === '/state' && req.method === 'GET') {
821
949
  if (!authorized(req, url)) {
822
950
  authFailures++
@@ -1371,6 +1499,37 @@ function fsUploadProbe(req, res, url) {
1371
1499
  })
1372
1500
  }
1373
1501
 
1502
+ /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
1503
+ function fsMkdir(req, res, url) {
1504
+ if (req.method !== 'POST') {
1505
+ res.writeHead(405, { allow: 'POST' })
1506
+ res.end()
1507
+ return
1508
+ }
1509
+ if (!fsAuthorized(req, url, res)) return
1510
+ touchDevice(req)
1511
+ const resolved = fsResolve(url.searchParams.get('path') ?? '')
1512
+ if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1513
+ const checked = fsRealChecked(resolved.abs)
1514
+ if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
1515
+ try {
1516
+ if (!fs.statSync(checked.abs).isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
1517
+ } catch (err) {
1518
+ return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
1519
+ }
1520
+
1521
+ const name = url.searchParams.get('name') || ''
1522
+ if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '目录名不能为空且不能包含路径分隔符' })
1523
+ const target = path.join(checked.abs, name)
1524
+ try {
1525
+ fs.mkdirSync(target)
1526
+ } catch (err) {
1527
+ if (err.code === 'EEXIST') return fsJson(res, 409, { error: 'exists' })
1528
+ return fsJson(res, ['EACCES', 'EPERM', 'EROFS'].includes(err.code) ? 403 : 400, { error: 'mkdir-failed', detail: err.message })
1529
+ }
1530
+ fsJson(res, 201, { ok: true, name, path: path.join(resolved.abs, name) })
1531
+ }
1532
+
1374
1533
  function fsUploadResumable(req, res, url, dirLex, dirReal) {
1375
1534
  const name = url.searchParams.get('name') || ''
1376
1535
  if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
@@ -1551,6 +1710,7 @@ function serveFs(req, res, url) {
1551
1710
 
1552
1711
  if (sub === '/list') return fsList(req, res, url)
1553
1712
  if (sub === '/file') return fsFile(req, res, url)
1713
+ if (sub === '/mkdir') return fsMkdir(req, res, url)
1554
1714
  if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
1555
1715
  if (sub === '/upload-control') return fsUploadControl(req, res, url)
1556
1716
 
@@ -1666,7 +1826,15 @@ async function serveHealth(res) {
1666
1826
  }
1667
1827
  cors(res)
1668
1828
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1669
- res.end(JSON.stringify({ ok: true, service: 'dsh-remote', version: VERSION, pid: process.pid, upstream: UPSTREAM.origin, upstreamOk }))
1829
+ res.end(JSON.stringify({
1830
+ ok: true,
1831
+ service: 'dsh-remote',
1832
+ version: VERSION,
1833
+ pid: process.pid,
1834
+ upstream: UPSTREAM.origin,
1835
+ upstreamOk,
1836
+ events: eventCollectorState,
1837
+ }))
1670
1838
  }
1671
1839
 
1672
1840
  function lanAddresses() {
@@ -1778,11 +1946,43 @@ server.on('upgrade', (req, socket, head) => {
1778
1946
  upSocket.setNoDelay(true)
1779
1947
  upSocket.pipe(socket)
1780
1948
  socket.pipe(upSocket)
1781
- const close = () => { upSocket.destroy(); socket.destroy() }
1949
+ // 双向 idle 检测: 任一侧 60s 无数据即视为死连接, 同时销毁两侧
1950
+ let upIdle = null
1951
+ let clientIdle = null
1952
+ const clearIdle = () => {
1953
+ clearTimeout(upIdle)
1954
+ clearTimeout(clientIdle)
1955
+ upIdle = null
1956
+ clientIdle = null
1957
+ }
1958
+ const destroyBoth = () => {
1959
+ clearIdle()
1960
+ upSocket.destroy()
1961
+ socket.destroy()
1962
+ }
1963
+ const close = () => {
1964
+ clearIdle()
1965
+ upSocket.destroy()
1966
+ socket.destroy()
1967
+ }
1968
+ const touchUp = () => {
1969
+ clearTimeout(upIdle)
1970
+ upIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1971
+ upIdle?.unref?.()
1972
+ }
1973
+ const touchClient = () => {
1974
+ clearTimeout(clientIdle)
1975
+ clientIdle = setTimeout(destroyBoth, WS_IDLE_MS)
1976
+ clientIdle?.unref?.()
1977
+ }
1978
+ upSocket.on('data', touchUp)
1979
+ socket.on('data', touchClient)
1980
+ touchUp()
1981
+ touchClient()
1782
1982
  upSocket.on('error', close)
1783
1983
  socket.on('error', close)
1784
- upSocket.on('close', () => socket.end())
1785
- socket.on('close', () => upSocket.end())
1984
+ upSocket.on('close', () => { clearIdle(); if (!socket.destroyed) socket.end() })
1985
+ socket.on('close', () => { clearIdle(); if (!upSocket.destroyed) upSocket.end() })
1786
1986
  })
1787
1987
 
1788
1988
  upstreamReq.on('error', () => {
package/index.mjs CHANGED
@@ -80,7 +80,12 @@ function lanIPs() {
80
80
  }
81
81
 
82
82
  function targetPath(pathname) {
83
- const rel = decodeURIComponent(pathname.slice(MOUNT.length)) || '/'
83
+ let rel
84
+ try {
85
+ rel = decodeURIComponent(pathname.slice(MOUNT.length)) || '/'
86
+ } catch {
87
+ return null
88
+ }
84
89
  const file = rel === '/' ? INDEX_FILE : rel.replace(/^\/+/, '')
85
90
  const abs = resolve(PUBLIC_DIR, normalize(file))
86
91
  if (abs !== PUBLIC_DIR && !abs.startsWith(PUBLIC_DIR)) return null
@@ -143,6 +148,24 @@ function runExit(cmd, args) {
143
148
  })
144
149
  }
145
150
 
151
+ const GATEWAY_ENV_KEYS = [
152
+ 'TOKEN', 'TOKEN_FILE', 'DSH_REMOTE_TOKEN', 'DSH_REMOTE_FS_ROOT', 'DSH_REMOTE_FS_MAX_UPLOAD',
153
+ 'DSH_REMOTE_NOTES', 'DSH_REMOTE_DSH_SERVICE', 'DSH_REMOTE_FEEDBACK_URL',
154
+ 'UPDATE_CHECK_URL', 'UPDATE_INTERVAL_MS', 'UPDATE_PROXY', 'GATEWAY_WS_IDLE_MS',
155
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
156
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'
157
+ ]
158
+
159
+ function gatewaySystemdEnvArgs() {
160
+ const args = []
161
+ for (const key of GATEWAY_ENV_KEYS) {
162
+ const value = process.env[key]
163
+ if (value === undefined || /[\0\r\n]/.test(value)) continue
164
+ args.push('--setenv=' + key + '=' + value)
165
+ }
166
+ return args
167
+ }
168
+
146
169
  /** 127.0.0.1 端口占用预检: 能连上=被占用, 连接被拒/超时=可用。 */
147
170
  function portInUse(port) {
148
171
  return new Promise((resolvePromise) => {
@@ -168,6 +191,7 @@ async function gatewayRunning() {
168
191
  return {
169
192
  running: true,
170
193
  pid: Number(data.pid) || 0,
194
+ version: typeof data.version === 'string' ? data.version : '',
171
195
  upstream: typeof data.upstream === 'string' ? data.upstream : '',
172
196
  upstreamOk: data.upstreamOk === true,
173
197
  }
@@ -264,6 +288,7 @@ async function startGateway() {
264
288
  await runExit('systemctl', ['--user', 'reset-failed', 'dsh-remote-gateway'])
265
289
  sysd = (await runExit('systemd-run', [
266
290
  '--user', '--unit=dsh-remote-gateway', '--service-type=exec',
291
+ ...gatewaySystemdEnvArgs(),
267
292
  '--setenv=PORT=' + port, '--setenv=HOST=0.0.0.0', '--setenv=DSH_UPSTREAM=' + upstream,
268
293
  '--', process.execPath, script,
269
294
  ])) === 0
@@ -317,8 +342,10 @@ function ensureGateway() {
317
342
  }
318
343
  const upstream = `http://${dshListen.host}:${dshListen.port}`
319
344
  const oldUpstream = health.upstream || ''
320
- if (health.upstreamOk === false || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
321
- logGateway(`网关上游需刷新: 旧=${oldUpstream || '?'} 新=${upstream}`)
345
+ const oldVersion = health.version || '?'
346
+ const versionMismatch = oldVersion !== version
347
+ if (versionMismatch || health.upstreamOk === false || (oldUpstream && oldUpstream !== upstream) || (!oldUpstream && upstream)) {
348
+ logGateway(`网关需刷新: 版本 ${oldVersion} -> ${version}, 上游 ${oldUpstream || '?'} -> ${upstream}`)
322
349
  await killGateway(health)
323
350
  for (let i = 0; i < 10; i++) {
324
351
  if (!(await gatewayRunning()).running) break
@@ -552,6 +579,9 @@ async function serveStatic(req, res, ctx) {
552
579
  return
553
580
  }
554
581
  const oldPort = Number(readGatewayPort())
582
+ // 先在切换配置前读取旧端口上的健康状态;写入新端口后 gatewayRunning()
583
+ // 只会探测新端口,否则旧网关会变成孤儿进程继续占用旧端口。
584
+ const oldHealth = process.env.DSH_REMOTE_GATEWAY ? { running: false } : await gatewayRunning()
555
585
  try {
556
586
  mkdirSync(`${homedir()}/.dsh-remote`, { recursive: true })
557
587
  writeFileSync(gatewayPortFile(), String(port) + '\n')
@@ -561,14 +591,7 @@ async function serveStatic(req, res, ctx) {
561
591
  }
562
592
  const effectivePort = Number(readGatewayPort())
563
593
  if (effectivePort !== oldPort) {
564
- const h = await gatewayRunning()
565
- if (h.running) {
566
- await killGateway(h)
567
- for (let i = 0; i < 10; i++) {
568
- if (!(await gatewayRunning()).running) break
569
- await sleep(200)
570
- }
571
- }
594
+ if (oldHealth.running) await killGateway(oldHealth)
572
595
  }
573
596
  let running = (await gatewayRunning()).running
574
597
  if (gatewayAutostart()) {
@@ -587,7 +610,7 @@ async function serveStatic(req, res, ctx) {
587
610
  if (pathname === `${MOUNT}/admin/api/gateway`) {
588
611
  if (req.method === 'GET') {
589
612
  const h = await gatewayRunning()
590
- sendJson(res, 200, { ok: true, running: h.running, upstream: h.upstream || '', upstreamOk: h.upstreamOk === true })
613
+ sendJson(res, 200, { ok: true, running: h.running, version: h.version || '', upstream: h.upstream || '', upstreamOk: h.upstreamOk === true })
591
614
  return
592
615
  }
593
616
  if (req.method === 'POST') {
@@ -606,6 +629,23 @@ async function serveStatic(req, res, ctx) {
606
629
  return
607
630
  }
608
631
 
632
+ // 远程启动/重启 DSH: 内嵌抽屉通过插件前缀转发到独立网关。
633
+ if (pathname === `${MOUNT}/admin/api/dsh`) {
634
+ if (req.method !== 'GET' && req.method !== 'POST') {
635
+ res.writeHead(405, { allow: 'GET, POST' })
636
+ res.end()
637
+ return
638
+ }
639
+ const body = req.method === 'POST' ? await readBody(req, 4096) : ''
640
+ const proxied = await proxyGateway('/admin/api/dsh', req.method, body)
641
+ if (proxied !== null) {
642
+ sendJson(res, proxied.status, proxied.json)
643
+ } else {
644
+ sendJson(res, 502, { ok: false, error: '本地网关不可用,无法控制 DSH' })
645
+ }
646
+ return
647
+ }
648
+
609
649
  // 斜杠命令桥接:客户端 → 网关 → 插件端点 → ctx.commands.execute
610
650
  if (pathname === `${MOUNT}/api/command`) {
611
651
  if (req.method !== 'POST') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",