dsh-remote-plugin 0.6.5 → 0.6.7
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 +1 -1
- package/README.md +1 -1
- package/apk/dsh-remote.apk +0 -0
- package/gateway-stats.cjs +9 -2
- package/gateway.cjs +292 -7
- package/index.mjs +61 -12
- package/package.json +1 -1
- package/public/app.js +647 -54
- package/public/desktop/desktop.css +79 -5
- package/public/desktop/desktop.html +75 -7
- package/public/desktop/desktop.js +418 -22
- package/public/desktop/i18n.js +33 -1
- package/public/index.html +108 -13
- package/public/styles.css +82 -4
- package/public/update.json +12 -4
- package/public/version.json +1 -1
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 (
|
|
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
|
package/apk/dsh-remote.apk
CHANGED
|
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)
|
|
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,15 @@
|
|
|
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
|
+
* DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
|
|
23
24
|
*/
|
|
24
25
|
'use strict'
|
|
25
26
|
|
|
26
27
|
const http = require('node:http')
|
|
27
28
|
const https = require('node:https')
|
|
29
|
+
const { execFile } = require('node:child_process')
|
|
28
30
|
const fs = require('node:fs')
|
|
29
31
|
const path = require('node:path')
|
|
30
32
|
const os = require('node:os')
|
|
@@ -47,7 +49,9 @@ const WS_IDLE_MS = Number(process.env.GATEWAY_WS_IDLE_MS) || 60000
|
|
|
47
49
|
const UPSTREAM = new URL(process.env.DSH_UPSTREAM || 'http://127.0.0.1:3080')
|
|
48
50
|
const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
|
|
49
51
|
const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
|
|
52
|
+
const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
|
|
50
53
|
const STARTED_AT = Date.now()
|
|
54
|
+
const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
|
|
51
55
|
|
|
52
56
|
// 更新检查: GitHub 为默认源, 可用环境变量覆盖(国内镜像 / 代理)
|
|
53
57
|
const UPDATE_CHECK_URL = process.env.UPDATE_CHECK_URL ||
|
|
@@ -82,12 +86,13 @@ const MIME = {
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
// ---------- /fs 文件传输 ----------
|
|
85
|
-
// 允许访问的根目录: DSH_REMOTE_FS_ROOT
|
|
89
|
+
// 允许访问的根目录: DSH_REMOTE_FS_ROOT 使用系统路径分隔符分隔多个根,
|
|
90
|
+
// POSIX 为 ':'、Windows 为 ';';默认仅 ~。
|
|
86
91
|
// 所有 /fs/* 路径 resolve 后都必须位于某个根内, 已存在的路径还会用 realpath
|
|
87
92
|
// 复核一次, 防止 ../ 穿越与符号链接逃逸。
|
|
88
93
|
const FS_DEFAULT_ROOT = path.resolve(os.homedir())
|
|
89
94
|
const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
|
|
90
|
-
.split(
|
|
95
|
+
.split(path.delimiter)
|
|
91
96
|
.filter(Boolean)
|
|
92
97
|
.map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
|
|
93
98
|
const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
|
|
@@ -307,7 +312,9 @@ function httpGetJson(url, cb) {
|
|
|
307
312
|
const isHttps = u.protocol === 'https:'
|
|
308
313
|
const lib = isHttps ? https : http
|
|
309
314
|
const proxyEnv = process.env.UPDATE_PROXY ||
|
|
310
|
-
(isHttps
|
|
315
|
+
(isHttps
|
|
316
|
+
? (process.env.HTTPS_PROXY || process.env.https_proxy)
|
|
317
|
+
: (process.env.HTTP_PROXY || process.env.http_proxy)) || ''
|
|
311
318
|
const done = (err, value) => { if (settled) return; settled = true; cb(err, value) }
|
|
312
319
|
let settled = false
|
|
313
320
|
const timer = setTimeout(() => done(new Error('检查超时')), 6000)
|
|
@@ -413,6 +420,109 @@ function cors(res) {
|
|
|
413
420
|
res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
|
|
414
421
|
}
|
|
415
422
|
|
|
423
|
+
function readBody(req, maxBytes = 64 * 1024) {
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const chunks = []
|
|
426
|
+
let size = 0
|
|
427
|
+
let settled = false
|
|
428
|
+
req.on('data', chunk => {
|
|
429
|
+
if (settled) return
|
|
430
|
+
size += chunk.length
|
|
431
|
+
if (size > maxBytes) {
|
|
432
|
+
settled = true
|
|
433
|
+
reject(new Error('request body too large'))
|
|
434
|
+
req.destroy()
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
chunks.push(chunk)
|
|
438
|
+
})
|
|
439
|
+
req.on('end', () => {
|
|
440
|
+
if (settled) return
|
|
441
|
+
settled = true
|
|
442
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
443
|
+
})
|
|
444
|
+
req.on('error', err => {
|
|
445
|
+
if (settled) return
|
|
446
|
+
settled = true
|
|
447
|
+
reject(err)
|
|
448
|
+
})
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function execFileResult(file, args, timeout = 5000) {
|
|
453
|
+
return new Promise((resolvePromise) => {
|
|
454
|
+
execFile(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
455
|
+
resolvePromise({
|
|
456
|
+
ok: !error,
|
|
457
|
+
code: error?.code ?? 0,
|
|
458
|
+
stdout: String(stdout || '').trim(),
|
|
459
|
+
stderr: String(stderr || '').trim(),
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function dshServiceStatus() {
|
|
466
|
+
if (process.platform === 'win32') {
|
|
467
|
+
return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows 请配置 DSH_REMOTE_DSH_SERVICE 后接入任务计划程序' }
|
|
468
|
+
}
|
|
469
|
+
if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
470
|
+
return { ok: false, supported: false, running: false, service: DSH_SERVICE, message: '服务名配置不合法' }
|
|
471
|
+
}
|
|
472
|
+
const r = await execFileResult('systemctl', ['--user', 'is-active', DSH_SERVICE], 3000)
|
|
473
|
+
return { ok: true, supported: true, running: r.stdout === 'active', service: DSH_SERVICE, state: r.stdout || 'unknown', detail: r.stderr || '' }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function serveDshControl(req, res, url) {
|
|
477
|
+
if (req.method === 'OPTIONS') {
|
|
478
|
+
cors(res)
|
|
479
|
+
res.writeHead(204)
|
|
480
|
+
res.end()
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (!authorized(req, url)) {
|
|
484
|
+
authFailures++
|
|
485
|
+
touchDevice(req, { failedAuth: true })
|
|
486
|
+
cors(res)
|
|
487
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
|
|
488
|
+
res.end(JSON.stringify({ error: 'unauthorized' }))
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
touchDevice(req, { kind: 'admin' })
|
|
492
|
+
if (req.method === 'GET') {
|
|
493
|
+
cors(res)
|
|
494
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
495
|
+
res.end(JSON.stringify(await dshServiceStatus()))
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
if (req.method !== 'POST') {
|
|
499
|
+
cors(res)
|
|
500
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
501
|
+
res.end()
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
let body = {}
|
|
505
|
+
try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch {}
|
|
506
|
+
const action = body?.action
|
|
507
|
+
if (action !== 'start' && action !== 'restart') {
|
|
508
|
+
cors(res)
|
|
509
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
510
|
+
res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
if (process.platform === 'win32' || !/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
|
|
514
|
+
cors(res)
|
|
515
|
+
res.writeHead(501, { 'content-type': 'application/json; charset=utf-8' })
|
|
516
|
+
res.end(JSON.stringify({ ok: false, supported: false, error: '当前系统未配置可控的 dsh-web 服务', service: DSH_SERVICE }))
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
const r = await execFileResult('systemctl', ['--user', action, DSH_SERVICE], 10000)
|
|
520
|
+
const status = await dshServiceStatus()
|
|
521
|
+
cors(res)
|
|
522
|
+
res.writeHead(r.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
|
|
523
|
+
res.end(JSON.stringify({ ...status, ok: r.ok, action, detail: r.stderr || r.stdout || '' }))
|
|
524
|
+
}
|
|
525
|
+
|
|
416
526
|
// ---------- 事件轮询缓冲 ----------
|
|
417
527
|
// 网关自己维护到 DSH 的 mux/host WebSocket,把事件写入内存环形缓冲;
|
|
418
528
|
// 前端在 WebSocket 被隧道/受限网络阻断时改走 GET /api/events.poll 增量拉取。
|
|
@@ -420,6 +530,10 @@ const EVENT_BUFFER_MAX = 300
|
|
|
420
530
|
const EVENT_MAX_STRING = 16 * 1024
|
|
421
531
|
const eventBuffers = { mux: [], host: [] }
|
|
422
532
|
const eventNextSeq = { mux: 1, host: 1 }
|
|
533
|
+
const eventCollectorState = {
|
|
534
|
+
mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
|
|
535
|
+
host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '' },
|
|
536
|
+
}
|
|
423
537
|
|
|
424
538
|
/** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
|
|
425
539
|
function truncateEventValue(v, depth = 0) {
|
|
@@ -438,6 +552,7 @@ function truncateEventValue(v, depth = 0) {
|
|
|
438
552
|
|
|
439
553
|
function pushEvent(kind, full) {
|
|
440
554
|
if (!eventBuffers[kind] || !full || typeof full !== 'object') return
|
|
555
|
+
if (eventCollectorState[kind]) eventCollectorState[kind].lastEventAt = Date.now()
|
|
441
556
|
const buf = eventBuffers[kind]
|
|
442
557
|
buf.push({ seq: eventNextSeq[kind]++, ts: Date.now(), event: truncateEventValue(full) })
|
|
443
558
|
if (buf.length > EVENT_BUFFER_MAX) buf.shift()
|
|
@@ -488,6 +603,7 @@ function serveEventPoll(req, res, url) {
|
|
|
488
603
|
/** 网关自带上游事件采集:mux/host 各一条 WS,断线自动重连。 */
|
|
489
604
|
function startEventCollector(kind) {
|
|
490
605
|
if (typeof WebSocket !== 'function') return null
|
|
606
|
+
const state = eventCollectorState[kind]
|
|
491
607
|
let ws = null
|
|
492
608
|
let stopped = false
|
|
493
609
|
let retryTimer = null
|
|
@@ -501,6 +617,11 @@ function startEventCollector(kind) {
|
|
|
501
617
|
return
|
|
502
618
|
}
|
|
503
619
|
ws.onopen = () => {
|
|
620
|
+
if (state) {
|
|
621
|
+
state.connected = true
|
|
622
|
+
state.lastConnectAt = Date.now()
|
|
623
|
+
state.lastError = ''
|
|
624
|
+
}
|
|
504
625
|
if (stopped) { try { ws.close() } catch {} }
|
|
505
626
|
}
|
|
506
627
|
ws.onmessage = (ev) => {
|
|
@@ -511,10 +632,17 @@ function startEventCollector(kind) {
|
|
|
511
632
|
} catch {}
|
|
512
633
|
}
|
|
513
634
|
ws.onclose = () => {
|
|
635
|
+
if (state) {
|
|
636
|
+
state.connected = false
|
|
637
|
+
state.reconnects++
|
|
638
|
+
}
|
|
514
639
|
ws = null
|
|
515
640
|
if (!stopped) retryTimer = setTimeout(connect, 3000)
|
|
516
641
|
}
|
|
517
|
-
ws.onerror = () => {
|
|
642
|
+
ws.onerror = (err) => {
|
|
643
|
+
if (state) state.lastError = String(err?.message || 'websocket error')
|
|
644
|
+
try { ws.close() } catch {}
|
|
645
|
+
}
|
|
518
646
|
}
|
|
519
647
|
connect()
|
|
520
648
|
return {
|
|
@@ -788,11 +916,20 @@ function serveStatic(req, res, url) {
|
|
|
788
916
|
return
|
|
789
917
|
}
|
|
790
918
|
const ext = path.extname(filePath).toLowerCase()
|
|
919
|
+
const lastModified = st.mtime.toUTCString()
|
|
920
|
+
const mtimeSec = Math.floor(st.mtime.getTime() / 1000) * 1000
|
|
791
921
|
cors(res)
|
|
922
|
+
const ims = req.headers['if-modified-since']
|
|
923
|
+
if (ims && new Date(ims).getTime() >= mtimeSec) {
|
|
924
|
+
res.writeHead(304, { 'last-modified': lastModified })
|
|
925
|
+
res.end()
|
|
926
|
+
return
|
|
927
|
+
}
|
|
792
928
|
res.writeHead(200, {
|
|
793
929
|
'content-type': MIME[ext] || 'application/octet-stream',
|
|
794
930
|
'cache-control': ext === '.html' || ext === '.js' || ext === '.css' ? 'no-cache' : 'public, max-age=300',
|
|
795
|
-
'content-length': st.size
|
|
931
|
+
'content-length': st.size,
|
|
932
|
+
'last-modified': lastModified
|
|
796
933
|
})
|
|
797
934
|
if (req.method === 'HEAD') res.end()
|
|
798
935
|
else fs.createReadStream(filePath).pipe(res)
|
|
@@ -818,6 +955,7 @@ function upstreamReachable(cb) {
|
|
|
818
955
|
|
|
819
956
|
function serveAdminApi(req, res, url) {
|
|
820
957
|
const sub = url.pathname.slice('/admin/api'.length) || '/'
|
|
958
|
+
if (sub === '/dsh') return serveDshControl(req, res, url)
|
|
821
959
|
if (sub === '/state' && req.method === 'GET') {
|
|
822
960
|
if (!authorized(req, url)) {
|
|
823
961
|
authFailures++
|
|
@@ -1372,6 +1510,37 @@ function fsUploadProbe(req, res, url) {
|
|
|
1372
1510
|
})
|
|
1373
1511
|
}
|
|
1374
1512
|
|
|
1513
|
+
/** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
|
|
1514
|
+
function fsMkdir(req, res, url) {
|
|
1515
|
+
if (req.method !== 'POST') {
|
|
1516
|
+
res.writeHead(405, { allow: 'POST' })
|
|
1517
|
+
res.end()
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
if (!fsAuthorized(req, url, res)) return
|
|
1521
|
+
touchDevice(req)
|
|
1522
|
+
const resolved = fsResolve(url.searchParams.get('path') ?? '')
|
|
1523
|
+
if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
|
|
1524
|
+
const checked = fsRealChecked(resolved.abs)
|
|
1525
|
+
if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
|
|
1526
|
+
try {
|
|
1527
|
+
if (!fs.statSync(checked.abs).isDirectory()) return fsJson(res, 400, { error: 'not-a-directory' })
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
return fsJson(res, err.code === 'ENOENT' ? 404 : 403, { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' })
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const name = url.searchParams.get('name') || ''
|
|
1533
|
+
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '目录名不能为空且不能包含路径分隔符' })
|
|
1534
|
+
const target = path.join(checked.abs, name)
|
|
1535
|
+
try {
|
|
1536
|
+
fs.mkdirSync(target)
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
if (err.code === 'EEXIST') return fsJson(res, 409, { error: 'exists' })
|
|
1539
|
+
return fsJson(res, ['EACCES', 'EPERM', 'EROFS'].includes(err.code) ? 403 : 400, { error: 'mkdir-failed', detail: err.message })
|
|
1540
|
+
}
|
|
1541
|
+
fsJson(res, 201, { ok: true, name, path: path.join(resolved.abs, name) })
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1375
1544
|
function fsUploadResumable(req, res, url, dirLex, dirReal) {
|
|
1376
1545
|
const name = url.searchParams.get('name') || ''
|
|
1377
1546
|
if (!fsValidName(name)) return fsJson(res, 400, { error: 'bad-name', detail: '文件名不能为空且不能包含路径分隔符' })
|
|
@@ -1552,6 +1721,7 @@ function serveFs(req, res, url) {
|
|
|
1552
1721
|
|
|
1553
1722
|
if (sub === '/list') return fsList(req, res, url)
|
|
1554
1723
|
if (sub === '/file') return fsFile(req, res, url)
|
|
1724
|
+
if (sub === '/mkdir') return fsMkdir(req, res, url)
|
|
1555
1725
|
if (sub === '/upload-probe') return fsUploadProbe(req, res, url)
|
|
1556
1726
|
if (sub === '/upload-control') return fsUploadControl(req, res, url)
|
|
1557
1727
|
|
|
@@ -1594,6 +1764,112 @@ function serveFs(req, res, url) {
|
|
|
1594
1764
|
fsJson(res, 404, { error: 'not-found' })
|
|
1595
1765
|
}
|
|
1596
1766
|
|
|
1767
|
+
// ---------- /workbench 工作台绑定 ----------
|
|
1768
|
+
// 工作台绑定一个文件夹;其下的子文件夹由客户端映射为 DSH 项目工作区。
|
|
1769
|
+
function workbenchPathInfo(rawPath) {
|
|
1770
|
+
if (typeof rawPath !== 'string' || !path.isAbsolute(rawPath)) return { error: 'bad-path' }
|
|
1771
|
+
const abs = path.resolve(rawPath)
|
|
1772
|
+
let st
|
|
1773
|
+
try { st = fs.statSync(abs) } catch (err) {
|
|
1774
|
+
return { error: err.code === 'ENOENT' ? 'not-found' : 'permission-denied' }
|
|
1775
|
+
}
|
|
1776
|
+
if (!st.isDirectory()) return { error: 'not-a-directory' }
|
|
1777
|
+
const checked = fsRealChecked(abs)
|
|
1778
|
+
if (checked.error) return { error: checked.error === 'forbidden' ? 'outside-roots' : checked.error }
|
|
1779
|
+
return { path: checked.abs }
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function loadWorkbench() {
|
|
1783
|
+
try {
|
|
1784
|
+
const raw = JSON.parse(fs.readFileSync(WORKBENCH_FILE, 'utf8'))
|
|
1785
|
+
if (!raw || typeof raw.path !== 'string' || !raw.path) return null
|
|
1786
|
+
const checked = workbenchPathInfo(raw.path)
|
|
1787
|
+
return checked.path ? { path: checked.path } : null
|
|
1788
|
+
} catch {
|
|
1789
|
+
return null
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function saveWorkbench(binding) {
|
|
1794
|
+
try {
|
|
1795
|
+
fs.mkdirSync(path.dirname(WORKBENCH_FILE), { recursive: true })
|
|
1796
|
+
fs.writeFileSync(WORKBENCH_FILE, JSON.stringify(binding, null, 2) + '\n')
|
|
1797
|
+
return true
|
|
1798
|
+
} catch {
|
|
1799
|
+
return false
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function serveWorkbench(req, res, url) {
|
|
1804
|
+
const sub = url.pathname.slice('/workbench'.length)
|
|
1805
|
+
if (req.method === 'OPTIONS') {
|
|
1806
|
+
cors(res)
|
|
1807
|
+
res.writeHead(204)
|
|
1808
|
+
res.end()
|
|
1809
|
+
return
|
|
1810
|
+
}
|
|
1811
|
+
if (!fsAuthorized(req, url, res)) return
|
|
1812
|
+
|
|
1813
|
+
if (sub === '' && req.method === 'GET') {
|
|
1814
|
+
const binding = loadWorkbench()
|
|
1815
|
+
fsJson(res, 200, {
|
|
1816
|
+
bound: !!binding,
|
|
1817
|
+
path: binding?.path || null,
|
|
1818
|
+
title: binding ? path.basename(binding.path) : null
|
|
1819
|
+
})
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if (sub === '/bind' && req.method === 'POST') {
|
|
1824
|
+
let body = ''
|
|
1825
|
+
let done = false
|
|
1826
|
+
const fail = (status, payload) => {
|
|
1827
|
+
if (done || res.headersSent) return
|
|
1828
|
+
done = true
|
|
1829
|
+
fsJson(res, status, payload)
|
|
1830
|
+
}
|
|
1831
|
+
req.on('data', chunk => {
|
|
1832
|
+
if (done) return
|
|
1833
|
+
body += chunk
|
|
1834
|
+
if (Buffer.byteLength(body) > 4096) {
|
|
1835
|
+
req.destroy()
|
|
1836
|
+
fail(413, { error: 'too-large' })
|
|
1837
|
+
}
|
|
1838
|
+
})
|
|
1839
|
+
req.on('error', () => { if (!done) done = true })
|
|
1840
|
+
req.on('end', () => {
|
|
1841
|
+
if (done) return
|
|
1842
|
+
try {
|
|
1843
|
+
const rawPath = JSON.parse(body || '{}')?.path
|
|
1844
|
+
const checked = workbenchPathInfo(rawPath)
|
|
1845
|
+
if (checked.error) {
|
|
1846
|
+
const status = checked.error === 'forbidden' ? 403 : 400
|
|
1847
|
+
fail(status, { error: checked.error, detail: checked.error === 'outside-roots' ? '绑定目录必须在文件传输允许根目录内' : undefined })
|
|
1848
|
+
return
|
|
1849
|
+
}
|
|
1850
|
+
if (!saveWorkbench({ path: checked.path })) {
|
|
1851
|
+
fail(500, { error: 'save-failed' })
|
|
1852
|
+
return
|
|
1853
|
+
}
|
|
1854
|
+
done = true
|
|
1855
|
+
fsJson(res, 200, { bound: true, path: checked.path, title: path.basename(checked.path) })
|
|
1856
|
+
} catch {
|
|
1857
|
+
fail(400, { error: 'bad-request' })
|
|
1858
|
+
}
|
|
1859
|
+
})
|
|
1860
|
+
return
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (sub === '/unbind' && req.method === 'POST') {
|
|
1864
|
+
try { fs.rmSync(WORKBENCH_FILE, { force: true }) } catch {}
|
|
1865
|
+
fsJson(res, 200, { bound: false })
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
1870
|
+
res.end()
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1597
1873
|
// ---------- /api 代理 ----------
|
|
1598
1874
|
function proxyApi(req, res, url) {
|
|
1599
1875
|
if (req.method === 'OPTIONS') {
|
|
@@ -1667,7 +1943,15 @@ async function serveHealth(res) {
|
|
|
1667
1943
|
}
|
|
1668
1944
|
cors(res)
|
|
1669
1945
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
1670
|
-
res.end(JSON.stringify({
|
|
1946
|
+
res.end(JSON.stringify({
|
|
1947
|
+
ok: true,
|
|
1948
|
+
service: 'dsh-remote',
|
|
1949
|
+
version: VERSION,
|
|
1950
|
+
pid: process.pid,
|
|
1951
|
+
upstream: UPSTREAM.origin,
|
|
1952
|
+
upstreamOk,
|
|
1953
|
+
events: eventCollectorState,
|
|
1954
|
+
}))
|
|
1671
1955
|
}
|
|
1672
1956
|
|
|
1673
1957
|
function lanAddresses() {
|
|
@@ -1684,6 +1968,7 @@ const server = http.createServer((req, res) => {
|
|
|
1684
1968
|
try {
|
|
1685
1969
|
const url = new URL(req.url, 'http://dsh-remote.local')
|
|
1686
1970
|
if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
|
|
1971
|
+
if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
|
|
1687
1972
|
if (url.pathname === '/feedback') return serveFeedback(req, res, url)
|
|
1688
1973
|
if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
|
|
1689
1974
|
if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
|
package/index.mjs
CHANGED
|
@@ -80,7 +80,12 @@ function lanIPs() {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function targetPath(pathname) {
|
|
83
|
-
|
|
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_WORKBENCH', '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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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') {
|
|
@@ -664,10 +704,19 @@ async function serveStatic(req, res, ctx) {
|
|
|
664
704
|
return
|
|
665
705
|
}
|
|
666
706
|
const { abs, info } = found
|
|
707
|
+
const lastModified = info.mtime.toUTCString()
|
|
708
|
+
const mtimeSec = Math.floor(info.mtime.getTime() / 1000) * 1000
|
|
709
|
+
const ims = req.headers['if-modified-since']
|
|
710
|
+
if (ims && new Date(ims).getTime() >= mtimeSec) {
|
|
711
|
+
res.writeHead(304, { 'last-modified': lastModified })
|
|
712
|
+
res.end()
|
|
713
|
+
return
|
|
714
|
+
}
|
|
667
715
|
res.writeHead(200, {
|
|
668
716
|
'content-type': MIME[extname(abs)] ?? 'application/octet-stream',
|
|
669
717
|
'content-length': info.size,
|
|
670
718
|
'cache-control': 'no-cache',
|
|
719
|
+
'last-modified': lastModified,
|
|
671
720
|
})
|
|
672
721
|
if (req.method === 'HEAD') {
|
|
673
722
|
res.end()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|