dsh-remote-plugin 0.6.11 → 0.6.12

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/gateway.cjs CHANGED
@@ -18,7 +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_FS_ROOT 文件传输允许根, 默认 ~, 使用系统路径分隔符配置多根
21
+ * DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
22
22
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
23
23
  * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
24
24
  */
@@ -44,6 +44,12 @@ try {
44
44
  const ROOT = __dirname
45
45
  const PUBLIC_DIR = path.join(ROOT, 'public')
46
46
  const ANNOUNCEMENTS_FILE = process.env.DSH_REMOTE_ANNOUNCEMENTS_FILE || path.join(PUBLIC_DIR, 'announcements.json')
47
+ const DEFAULT_ANNOUNCEMENTS_URL = 'https://vm-0-2-ubuntu.tail1f6fc4.ts.net/announcements.json'
48
+ const ANNOUNCEMENTS_URL = process.env.DSH_REMOTE_ANNOUNCEMENTS_URL === undefined
49
+ ? DEFAULT_ANNOUNCEMENTS_URL
50
+ : String(process.env.DSH_REMOTE_ANNOUNCEMENTS_URL || '').trim()
51
+ const ANNOUNCEMENTS_CACHE_MS = durationEnv('DSH_REMOTE_ANNOUNCEMENTS_CACHE_MS', 15_000, 100, 10 * 60_000)
52
+ const ANNOUNCEMENTS_MAX_BYTES = 512 * 1024
47
53
  const PORT = Number(process.env.PORT) || 8787
48
54
  const HOST = process.env.HOST || '0.0.0.0'
49
55
 
@@ -74,6 +80,9 @@ const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh
74
80
  const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
75
81
  const STARTED_AT = Date.now()
76
82
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
83
+ const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
84
+ const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
85
+ const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
77
86
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
78
87
  const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
79
88
  const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
@@ -120,8 +129,11 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
120
129
  .split(path.delimiter)
121
130
  .filter(Boolean)
122
131
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
132
+ const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
123
133
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
124
134
  let FS_ROOT_REALS = null
135
+ let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
136
+ let fsWorkspaceRootsFetch = null
125
137
  function fsRootReals() {
126
138
  if (!FS_ROOT_REALS) {
127
139
  FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
@@ -129,7 +141,7 @@ function fsRootReals() {
129
141
  return FS_ROOT_REALS
130
142
  }
131
143
  function fsInsideReal(real) {
132
- for (const root of fsRootReals()) {
144
+ for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
133
145
  if (real === root || real.startsWith(root + path.sep)) return true
134
146
  }
135
147
  return false
@@ -557,6 +569,10 @@ function execFileResult(file, args, timeout = 5000) {
557
569
  resolvePromise({
558
570
  ok: !error,
559
571
  code: error?.code ?? 0,
572
+ signal: error?.signal || '',
573
+ killed: error?.killed === true,
574
+ timedOut: error?.code === 'ETIMEDOUT' || (error?.killed === true && error?.signal === 'SIGTERM'),
575
+ error: String(error?.message || '').trim(),
560
576
  stdout: String(stdout || '').trim(),
561
577
  stderr: String(stderr || '').trim(),
562
578
  })
@@ -564,15 +580,227 @@ function execFileResult(file, args, timeout = 5000) {
564
580
  })
565
581
  }
566
582
 
583
+ function parseSystemdShow(output) {
584
+ const values = {}
585
+ for (const line of String(output || '').split(/\r?\n/)) {
586
+ const split = line.indexOf('=')
587
+ if (split > 0) values[line.slice(0, split)] = line.slice(split + 1)
588
+ }
589
+ return values
590
+ }
591
+
592
+ function classifySystemctlFailure(result) {
593
+ const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
594
+ if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'systemctl 命令执行超时', detail }
595
+ if (result?.code === 'ENOENT' || /ENOENT|not found/i.test(detail)) return { code: 'SYSTEMCTL_NOT_FOUND', message: '系统中找不到 systemctl', detail }
596
+ if (/Failed to connect to bus|No medium found|user bus|DBUS/i.test(detail)) return { code: 'SYSTEMD_UNAVAILABLE', message: '无法连接当前用户的 systemd 会话', detail }
597
+ if (/access denied|permission denied|not authorized|authentication is required/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 DSH 服务', detail }
598
+ return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
599
+ }
600
+
567
601
  async function dshServiceStatus() {
568
602
  if (process.platform === 'win32') {
569
- return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows 请配置 DSH_REMOTE_DSH_SERVICE 后接入任务计划程序' }
603
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
570
604
  }
571
605
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
572
- return { ok: false, supported: false, running: false, service: DSH_SERVICE, message: '服务名配置不合法' }
606
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
607
+ }
608
+ const r = await execFileResult(SYSTEMCTL, [
609
+ '--user', 'show', DSH_SERVICE,
610
+ '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
611
+ '--no-pager'
612
+ ], 5000)
613
+ if (!r.ok) {
614
+ const failure = classifySystemctlFailure(r)
615
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
616
+ }
617
+ const value = parseSystemdShow(r.stdout)
618
+ const loadState = value.LoadState || 'unknown'
619
+ const activeState = value.ActiveState || 'unknown'
620
+ const subState = value.SubState || 'unknown'
621
+ const mainPid = Number(value.MainPID) || 0
622
+ if (loadState === 'not-found') {
623
+ return {
624
+ ok: true, supported: false, running: false, service: DSH_SERVICE,
625
+ code: 'SERVICE_NOT_FOUND', message: `未找到 systemd 用户服务 ${DSH_SERVICE}`,
626
+ loadState, activeState, subState, mainPid,
627
+ }
628
+ }
629
+ return {
630
+ ok: true,
631
+ supported: true,
632
+ running: activeState === 'active' && (subState === 'running' || subState === 'exited'),
633
+ service: value.Id || DSH_SERVICE,
634
+ state: activeState,
635
+ loadState,
636
+ activeState,
637
+ subState,
638
+ unitFileState: value.UnitFileState || '',
639
+ mainPid,
640
+ result: value.Result || '',
641
+ execMainStatus: Number(value.ExecMainStatus) || 0,
642
+ }
643
+ }
644
+
645
+ function delay(ms) {
646
+ return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
647
+ }
648
+
649
+ async function probeDshUpstream() {
650
+ const startedAt = Date.now()
651
+ try {
652
+ const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
653
+ signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
654
+ cache: 'no-store',
655
+ })
656
+ return {
657
+ ok: probe.ok,
658
+ reachable: true,
659
+ status: probe.status,
660
+ elapsedMs: Date.now() - startedAt,
661
+ error: probe.ok ? '' : `DSH HTTP ${probe.status}`,
662
+ }
663
+ } catch (err) {
664
+ return { ok: false, reachable: false, status: 0, elapsedMs: Date.now() - startedAt, error: String(err?.message || err || '连接失败').slice(0, 500) }
665
+ }
666
+ }
667
+
668
+ let dshControlOperation = null
669
+
670
+ function dshOperationStep(operation, stage, message, extra = {}) {
671
+ const now = Date.now()
672
+ operation.stage = stage
673
+ operation.message = message
674
+ operation.updatedAt = now
675
+ Object.assign(operation, extra)
676
+ if (operation.done) operation.elapsedMs = now - operation.startedAt
677
+ operation.steps.push({ stage, message, at: now, elapsedMs: now - operation.startedAt })
678
+ }
679
+
680
+ function failDshOperation(operation, code, message, detail = '', status = null) {
681
+ dshOperationStep(operation, 'failed', message, {
682
+ ok: false,
683
+ done: true,
684
+ code,
685
+ detail: String(detail || '').slice(0, 1000),
686
+ ...(status ? { status } : {}),
687
+ })
688
+ }
689
+
690
+ function dshEventChannelStatus() {
691
+ const pick = kind => ({
692
+ connected: eventCollectorState[kind].connected,
693
+ attempt: eventCollectorState[kind].attempt,
694
+ lastError: eventCollectorState[kind].lastError,
695
+ })
696
+ const mux = pick('mux')
697
+ const host = pick('host')
698
+ return { ok: mux.connected && host.connected, mux, host }
699
+ }
700
+
701
+ function reconnectDshEventCollectors() {
702
+ eventCollectors.mux?.reconnectNow()
703
+ eventCollectors.host?.reconnectNow()
704
+ }
705
+
706
+ async function runDshControlOperation(operation) {
707
+ try {
708
+ dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
709
+ const initial = await dshServiceStatus()
710
+ operation.initialStatus = initial
711
+ if (!initial.supported) {
712
+ failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
713
+ return
714
+ }
715
+ if (operation.action === 'start' && initial.running) {
716
+ dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
717
+ ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
718
+ })
719
+ return
720
+ }
721
+
722
+ dshOperationStep(operation, 'command', `正在向 systemd 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
723
+ const command = await execFileResult(SYSTEMCTL, ['--user', '--no-block', operation.action, DSH_SERVICE], 5000)
724
+ operation.command = { ok: command.ok, code: command.code, signal: command.signal }
725
+ if (!command.ok) {
726
+ const failure = classifySystemctlFailure(command)
727
+ failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
728
+ return
729
+ }
730
+
731
+ dshOperationStep(operation, 'waiting-service', `命令已接受,正在等待 ${initial.service} 进入运行状态`)
732
+ const initialPid = initial.mainPid || 0
733
+ let restartObserved = operation.action === 'start' || !initial.running || initialPid <= 0
734
+ let waitingUpstreamReported = false
735
+ let waitingEventsReported = false
736
+ let lastEventReconnectAt = 0
737
+ let lastStatus = initial
738
+ let lastProbe = null
739
+ const deadline = Date.now() + DSH_CONTROL_TIMEOUT_MS
740
+ while (Date.now() < deadline) {
741
+ const status = await dshServiceStatus()
742
+ lastStatus = status
743
+ operation.status = status
744
+ if (!status.supported) {
745
+ failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
746
+ return
747
+ }
748
+ if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
749
+ if (status.activeState === 'failed') {
750
+ failDshOperation(operation, 'SERVICE_FAILED', `DSH 服务进入 failed 状态(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, '', status)
751
+ return
752
+ }
753
+ if (status.running && restartObserved) {
754
+ if (!waitingUpstreamReported) {
755
+ waitingUpstreamReported = true
756
+ dshOperationStep(operation, 'waiting-upstream', `服务进程已运行(PID ${status.mainPid || '未知'}),正在等待 DSH HTTP 接口 ${UPSTREAM.origin}${DSH_HEALTH_PATH} 恢复`)
757
+ }
758
+ lastProbe = await probeDshUpstream()
759
+ operation.upstream = lastProbe
760
+ if (lastProbe.ok) {
761
+ if (!waitingEventsReported) {
762
+ waitingEventsReported = true
763
+ dshOperationStep(operation, 'waiting-events', `DSH HTTP 已恢复(${lastProbe.status}),正在连接 mux/host 实时消息通道`)
764
+ }
765
+ if (Date.now() - lastEventReconnectAt >= 1500) {
766
+ lastEventReconnectAt = Date.now()
767
+ reconnectDshEventCollectors()
768
+ }
769
+ const events = dshEventChannelStatus()
770
+ operation.events = events
771
+ if (events.ok) {
772
+ dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
773
+ ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
774
+ })
775
+ return
776
+ }
777
+ }
778
+ }
779
+ await delay(DSH_CONTROL_POLL_MS)
780
+ }
781
+ if (!lastStatus.running || !restartObserved) {
782
+ const reason = operation.action === 'restart' && !restartObserved
783
+ ? `未观察到 ${initial.service} 进程完成重启(初始 PID ${initialPid || '未知'},当前 PID ${lastStatus.mainPid || '未知'})`
784
+ : `${initial.service} 未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内进入运行状态(${lastStatus.activeState}/${lastStatus.subState})`
785
+ failDshOperation(operation, 'SERVICE_TIMEOUT', reason, '', lastStatus)
786
+ return
787
+ }
788
+ if (lastProbe?.ok) {
789
+ const events = dshEventChannelStatus()
790
+ failDshOperation(
791
+ operation,
792
+ 'EVENTS_TIMEOUT',
793
+ `DSH 服务和 HTTP 已恢复,但 mux/host 实时消息通道未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内连接`,
794
+ ['mux', 'host'].map(kind => `${kind}: ${events[kind].connected ? 'connected' : events[kind].lastError || `retry ${events[kind].attempt}`}`).join(' · '),
795
+ lastStatus,
796
+ )
797
+ operation.events = events
798
+ return
799
+ }
800
+ failDshOperation(operation, 'UPSTREAM_TIMEOUT', `服务进程已运行,但 DSH HTTP 接口在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内未恢复`, lastProbe?.error || '', lastStatus)
801
+ } catch (err) {
802
+ failDshOperation(operation, 'INTERNAL_ERROR', 'DSH 控制流程发生未预期错误', String(err?.stack || err))
573
803
  }
574
- const r = await execFileResult('systemctl', ['--user', 'is-active', DSH_SERVICE], 3000)
575
- return { ok: true, supported: true, running: r.stdout === 'active', service: DSH_SERVICE, state: r.stdout || 'unknown', detail: r.stderr || '' }
576
804
  }
577
805
 
578
806
  async function serveDshControl(req, res, url) {
@@ -592,9 +820,26 @@ async function serveDshControl(req, res, url) {
592
820
  }
593
821
  touchDevice(req, { kind: 'admin' })
594
822
  if (req.method === 'GET') {
823
+ const operationId = String(url.searchParams.get('operation') || '').trim()
595
824
  cors(res)
825
+ if (operationId) {
826
+ if (!dshControlOperation || dshControlOperation.operationId !== operationId) {
827
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
828
+ res.end(JSON.stringify({ ok: false, done: true, code: 'OPERATION_NOT_FOUND', error: '找不到该 DSH 控制操作,网关可能已重启' }))
829
+ return
830
+ }
831
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
832
+ res.end(JSON.stringify({
833
+ ...dshControlOperation,
834
+ elapsedMs: dshControlOperation.done
835
+ ? dshControlOperation.elapsedMs
836
+ : Date.now() - dshControlOperation.startedAt,
837
+ }))
838
+ return
839
+ }
596
840
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
597
- res.end(JSON.stringify(await dshServiceStatus()))
841
+ const status = await dshServiceStatus()
842
+ res.end(JSON.stringify({ ...status, operation: dshControlOperation && !dshControlOperation.done ? dshControlOperation : null }))
598
843
  return
599
844
  }
600
845
  if (req.method !== 'POST') {
@@ -604,25 +849,36 @@ async function serveDshControl(req, res, url) {
604
849
  return
605
850
  }
606
851
  let body = {}
607
- try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch {}
852
+ try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch (err) {
853
+ cors(res)
854
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
855
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_JSON', error: '请求体不是有效 JSON', detail: String(err?.message || err) }))
856
+ return
857
+ }
608
858
  const action = body?.action
609
859
  if (action !== 'start' && action !== 'restart') {
610
860
  cors(res)
611
861
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
612
- res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
862
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_ACTION', error: 'action 必须是 start 或 restart' }))
613
863
  return
614
864
  }
615
- if (process.platform === 'win32' || !/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
865
+ if (dshControlOperation && !dshControlOperation.done) {
616
866
  cors(res)
617
- res.writeHead(501, { 'content-type': 'application/json; charset=utf-8' })
618
- res.end(JSON.stringify({ ok: false, supported: false, error: '当前系统未配置可控的 dsh-web 服务', service: DSH_SERVICE }))
867
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
868
+ res.end(JSON.stringify({ ok: false, code: 'OPERATION_IN_PROGRESS', error: '已有 DSH 控制操作正在执行', operation: dshControlOperation }))
619
869
  return
620
870
  }
621
- const r = await execFileResult('systemctl', ['--user', action, DSH_SERVICE], 10000)
622
- const status = await dshServiceStatus()
871
+ const now = Date.now()
872
+ dshControlOperation = {
873
+ operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
874
+ ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
875
+ message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
876
+ startedAt: now, updatedAt: now, steps: [],
877
+ }
878
+ setImmediate(() => { void runDshControlOperation(dshControlOperation) })
623
879
  cors(res)
624
- res.writeHead(r.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
625
- res.end(JSON.stringify({ ...status, ok: r.ok, action, detail: r.stderr || r.stdout || '' }))
880
+ res.writeHead(202, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
881
+ res.end(JSON.stringify(dshControlOperation))
626
882
  }
627
883
 
628
884
  // ---------- 事件轮询缓冲 ----------
@@ -640,6 +896,7 @@ const eventCollectorState = {
640
896
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
641
897
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
642
898
  }
899
+ const eventCollectors = { mux: null, host: null }
643
900
 
644
901
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
645
902
  function truncateEventValue(v, depth = 0) {
@@ -876,6 +1133,13 @@ function startEventCollector(kind) {
876
1133
  connect()
877
1134
  return {
878
1135
  kind,
1136
+ reconnectNow() {
1137
+ if (stopped || state.connected || ws?.readyState === 0) return
1138
+ clearTimeout(retryTimer)
1139
+ retryTimer = null
1140
+ state.attempt = 0
1141
+ connect()
1142
+ },
879
1143
  close() {
880
1144
  stopped = true
881
1145
  clearTimeout(retryTimer)
@@ -1000,15 +1264,106 @@ function maskIp(ip) {
1000
1264
  return s
1001
1265
  }
1002
1266
 
1003
- function validatePollVote(payload) {
1004
- const announcementId = String(payload.announcementId || '').trim()
1005
- const pollId = String(payload.pollId || '').trim()
1006
- const optionId = String(payload.optionId || '').trim()
1007
- if (!announcementId || !pollId || !optionId) return { error: 'poll fields required' }
1008
- if (announcementId.length > 120 || pollId.length > 120 || optionId.length > 120) return { error: 'poll fields too long' }
1009
- let source
1010
- try { source = JSON.parse(fs.readFileSync(ANNOUNCEMENTS_FILE, 'utf8')) } catch { return { error: 'poll config unavailable' } }
1011
- const items = Array.isArray(source) ? source : (Array.isArray(source?.items) ? source.items : [source])
1267
+ let announcementsCache = null
1268
+ let announcementsFetch = null
1269
+
1270
+ function parseAnnouncements(raw) {
1271
+ if (Buffer.byteLength(raw, 'utf8') > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1272
+ const data = JSON.parse(raw)
1273
+ const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
1274
+ if (items.length > 200 || items.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
1275
+ throw new Error('invalid announcements payload')
1276
+ }
1277
+ return { data: Array.isArray(data) ? { items: data } : data, items }
1278
+ }
1279
+
1280
+ function localAnnouncements() {
1281
+ try {
1282
+ const raw = fs.readFileSync(ANNOUNCEMENTS_FILE, 'utf8')
1283
+ const parsed = parseAnnouncements(raw)
1284
+ return { ...parsed, raw: JSON.stringify(parsed.data), source: 'local', stale: false, fetchedAt: Date.now() }
1285
+ } catch {
1286
+ const data = { items: [] }
1287
+ return { data, items: data.items, raw: JSON.stringify(data), source: 'empty', stale: false, fetchedAt: Date.now() }
1288
+ }
1289
+ }
1290
+
1291
+ function safeAnnouncementsUrl(value) {
1292
+ const target = new URL(value)
1293
+ const loopback = ['127.0.0.1', '::1', 'localhost'].includes(target.hostname)
1294
+ if (target.protocol !== 'https:' && !(target.protocol === 'http:' && loopback)) {
1295
+ throw new Error('central announcements URL must use HTTPS')
1296
+ }
1297
+ return target.href
1298
+ }
1299
+
1300
+ async function loadCentralAnnouncements(force = false) {
1301
+ const now = Date.now()
1302
+ if (!ANNOUNCEMENTS_URL) return localAnnouncements()
1303
+ if (!force && announcementsCache && now - announcementsCache.fetchedAt < ANNOUNCEMENTS_CACHE_MS) return announcementsCache
1304
+ if (announcementsFetch) return announcementsFetch
1305
+ announcementsFetch = (async () => {
1306
+ try {
1307
+ const headers = { accept: 'application/json' }
1308
+ if (announcementsCache?.etag) headers['if-none-match'] = announcementsCache.etag
1309
+ const res = await fetch(safeAnnouncementsUrl(ANNOUNCEMENTS_URL), {
1310
+ headers,
1311
+ cache: 'no-store',
1312
+ redirect: 'follow',
1313
+ signal: AbortSignal.timeout(8000),
1314
+ })
1315
+ safeAnnouncementsUrl(res.url)
1316
+ if (res.status === 304 && announcementsCache) {
1317
+ announcementsCache = { ...announcementsCache, fetchedAt: now, stale: false }
1318
+ return announcementsCache
1319
+ }
1320
+ if (!res.ok) throw new Error(`central announcements HTTP ${res.status}`)
1321
+ const declared = Number(res.headers.get('content-length') || 0)
1322
+ if (declared > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1323
+ const raw = await res.text()
1324
+ const parsed = parseAnnouncements(raw)
1325
+ announcementsCache = {
1326
+ ...parsed,
1327
+ raw: JSON.stringify(parsed.data),
1328
+ source: 'central',
1329
+ stale: false,
1330
+ fetchedAt: now,
1331
+ etag: String(res.headers.get('etag') || ''),
1332
+ }
1333
+ return announcementsCache
1334
+ } catch (err) {
1335
+ if (announcementsCache?.source === 'central') {
1336
+ return { ...announcementsCache, stale: true, error: String(err?.message || err) }
1337
+ }
1338
+ return { ...localAnnouncements(), error: String(err?.message || err) }
1339
+ } finally {
1340
+ announcementsFetch = null
1341
+ }
1342
+ })()
1343
+ return announcementsFetch
1344
+ }
1345
+
1346
+ async function serveAnnouncements(req, res) {
1347
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1348
+ res.writeHead(405, { allow: 'GET, HEAD' })
1349
+ res.end()
1350
+ return
1351
+ }
1352
+ const snapshot = await loadCentralAnnouncements()
1353
+ cors(res)
1354
+ res.writeHead(200, {
1355
+ 'content-type': 'application/json; charset=utf-8',
1356
+ 'content-length': Buffer.byteLength(snapshot.raw),
1357
+ 'cache-control': 'no-store',
1358
+ 'x-content-type-options': 'nosniff',
1359
+ 'x-dsh-announcements-source': snapshot.source,
1360
+ ...(snapshot.stale ? { warning: '110 - "Response is stale"' } : {}),
1361
+ })
1362
+ if (req.method === 'HEAD') res.end()
1363
+ else res.end(snapshot.raw)
1364
+ }
1365
+
1366
+ function findPollVote(items, announcementId, pollId, optionId) {
1012
1367
  const announcement = items.find(item => String(item?.id || '').trim() === announcementId)
1013
1368
  const poll = announcement?.poll
1014
1369
  if (!poll || String(poll.id || '').trim() !== pollId || !Array.isArray(poll.options)) return { error: 'poll not found' }
@@ -1019,6 +1374,22 @@ function validatePollVote(payload) {
1019
1374
  return { announcementId, pollId, optionId, optionLabel }
1020
1375
  }
1021
1376
 
1377
+ async function validatePollVote(payload) {
1378
+ const announcementId = String(payload.announcementId || '').trim()
1379
+ const pollId = String(payload.pollId || '').trim()
1380
+ const optionId = String(payload.optionId || '').trim()
1381
+ if (!announcementId || !pollId || !optionId) return { error: 'poll fields required' }
1382
+ if (announcementId.length > 120 || pollId.length > 120 || optionId.length > 120) return { error: 'poll fields too long' }
1383
+ let snapshot = await loadCentralAnnouncements()
1384
+ let result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1385
+ // 中央公告刚发布、网关缓存尚未到期时,投票请求触发一次强制刷新,避免出现公告可见但选项暂不可投。
1386
+ if (result.error && ANNOUNCEMENTS_URL) {
1387
+ snapshot = await loadCentralAnnouncements(true)
1388
+ result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1389
+ }
1390
+ return result
1391
+ }
1392
+
1022
1393
  function serveFeedback(req, res, url) {
1023
1394
  cors(res)
1024
1395
  if (req.method === 'OPTIONS') {
@@ -1042,7 +1413,7 @@ function serveFeedback(req, res, url) {
1042
1413
 
1043
1414
  let body = ''
1044
1415
  req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
1045
- req.on('end', () => {
1416
+ req.on('end', async () => {
1046
1417
  let payload
1047
1418
  try {
1048
1419
  payload = JSON.parse(body || '{}')
@@ -1060,7 +1431,7 @@ function serveFeedback(req, res, url) {
1060
1431
  res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
1061
1432
  return
1062
1433
  }
1063
- const pollVote = type === 'poll' ? validatePollVote(payload) : null
1434
+ const pollVote = type === 'poll' ? await validatePollVote(payload) : null
1064
1435
  if (pollVote?.error) {
1065
1436
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1066
1437
  res.end(JSON.stringify({ error: pollVote.error }))
@@ -1147,21 +1518,7 @@ function serveStatic(req, res, url) {
1147
1518
  if (pathname === '/') pathname = '/index.html'
1148
1519
  if (pathname === '/admin') pathname = '/admin.html'
1149
1520
  if (pathname === '/announcements.json') {
1150
- fs.readFile(ANNOUNCEMENTS_FILE, 'utf8', (err, raw) => {
1151
- if (err) {
1152
- res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
1153
- res.end('404 Not Found')
1154
- return
1155
- }
1156
- try { JSON.parse(raw) } catch {
1157
- res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1158
- res.end(JSON.stringify({ error: 'invalid announcements config' }))
1159
- return
1160
- }
1161
- cors(res)
1162
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1163
- res.end(req.method === 'HEAD' ? '' : raw)
1164
- })
1521
+ void serveAnnouncements(req, res)
1165
1522
  return
1166
1523
  }
1167
1524
  // 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
@@ -1390,17 +1747,61 @@ function fsAuthorized(req, url, res) {
1390
1747
  return true
1391
1748
  }
1392
1749
 
1393
- /** 把用户给的 path 解析为绝对路径并做词法根检查; 返回 {abs} {error}。 */
1394
- function fsResolve(input) {
1750
+ function fsInsideRoot(abs, root) {
1751
+ return abs === root || abs.startsWith(root + path.sep)
1752
+ }
1753
+
1754
+ function fsWorkspacePath(value) {
1755
+ const raw = String(value?.path || value?.cwd || value?.root || '').trim()
1756
+ if (!raw || !path.isAbsolute(raw)) return ''
1757
+ return path.resolve(raw)
1758
+ }
1759
+
1760
+ async function loadFsWorkspaceRoots(force = false) {
1761
+ const now = Date.now()
1762
+ if (!force && now - fsWorkspaceRootsCache.fetchedAt < FS_WORKSPACE_CACHE_MS) return fsWorkspaceRootsCache
1763
+ if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
1764
+ fsWorkspaceRootsFetch = (async () => {
1765
+ try {
1766
+ const target = new URL('/api/workspace.list', UPSTREAM)
1767
+ const res = await fetch(target, {
1768
+ method: 'POST',
1769
+ headers: { 'content-type': 'application/json' },
1770
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
1771
+ signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
1772
+ })
1773
+ if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
1774
+ const body = await res.json()
1775
+ const value = body?.result?.ok ? body.result.value : null
1776
+ const items = Array.isArray(value?.items) ? value.items : []
1777
+ const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
1778
+ const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
1779
+ fsWorkspaceRootsCache = { roots, reals, fetchedAt: Date.now() }
1780
+ } catch {
1781
+ // DSH 重启期间保留上次成功的工作区根;缓存为空时仍仅允许显式 FS_ROOTS。
1782
+ fsWorkspaceRootsCache = { ...fsWorkspaceRootsCache, fetchedAt: Date.now() }
1783
+ } finally {
1784
+ fsWorkspaceRootsFetch = null
1785
+ }
1786
+ return fsWorkspaceRootsCache
1787
+ })()
1788
+ return fsWorkspaceRootsFetch
1789
+ }
1790
+
1791
+ /** 把用户给的 path 解析为绝对路径,并仅允许显式根或 DSH 已登记工作区。 */
1792
+ async function fsResolve(input) {
1395
1793
  const raw = String(input ?? '').trim()
1396
1794
  let abs
1397
1795
  if (!raw || raw === '~') abs = FS_ROOTS[0]
1398
1796
  else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
1399
1797
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
1400
1798
  else abs = path.resolve(FS_ROOTS[0], raw) // 相对路径按默认根解析
1401
- for (const root of FS_ROOTS) {
1402
- if (abs === root || abs.startsWith(root + path.sep)) return { abs }
1403
- }
1799
+ if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
1800
+ let workspaces = await loadFsWorkspaceRoots(false)
1801
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
1802
+ // 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
1803
+ workspaces = await loadFsWorkspaceRoots(true)
1804
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
1404
1805
  return { error: 'forbidden' }
1405
1806
  }
1406
1807
 
@@ -1443,14 +1844,14 @@ function fsParseRange(header, size) {
1443
1844
  return { start, end: Math.min(end, size - 1) }
1444
1845
  }
1445
1846
 
1446
- function fsList(req, res, url) {
1847
+ async function fsList(req, res, url) {
1447
1848
  if (req.method !== 'GET') {
1448
1849
  res.writeHead(405, { allow: 'GET' })
1449
1850
  res.end()
1450
1851
  return
1451
1852
  }
1452
1853
  if (!fsAuthorized(req, url, res)) return
1453
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
1854
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1454
1855
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1455
1856
  const checked = fsRealChecked(resolved.abs)
1456
1857
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1493,14 +1894,14 @@ function fsList(req, res, url) {
1493
1894
  fsJson(res, 200, { path: resolved.abs, entries })
1494
1895
  }
1495
1896
 
1496
- function fsFile(req, res, url) {
1897
+ async function fsFile(req, res, url) {
1497
1898
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1498
1899
  res.writeHead(405, { allow: 'GET, HEAD' })
1499
1900
  res.end()
1500
1901
  return
1501
1902
  }
1502
1903
  if (!fsAuthorized(req, url, res)) return
1503
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
1904
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1504
1905
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1505
1906
  const checked = fsRealChecked(resolved.abs)
1506
1907
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1541,14 +1942,14 @@ function fsFile(req, res, url) {
1541
1942
  stream.pipe(res)
1542
1943
  }
1543
1944
 
1544
- function fsPreview(req, res, url) {
1945
+ async function fsPreview(req, res, url) {
1545
1946
  if (req.method !== 'GET') {
1546
1947
  res.writeHead(405, { allow: 'GET' })
1547
1948
  res.end()
1548
1949
  return
1549
1950
  }
1550
1951
  if (!fsAuthorized(req, url, res)) return
1551
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
1952
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1552
1953
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1553
1954
  const checked = fsRealChecked(resolved.abs)
1554
1955
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1797,7 +2198,7 @@ function fsTargetState(target) {
1797
2198
  }
1798
2199
  }
1799
2200
 
1800
- function fsUploadProbe(req, res, url) {
2201
+ async function fsUploadProbe(req, res, url) {
1801
2202
  if (req.method !== 'GET') {
1802
2203
  res.writeHead(405, { allow: 'GET' })
1803
2204
  res.end()
@@ -1805,7 +2206,7 @@ function fsUploadProbe(req, res, url) {
1805
2206
  }
1806
2207
  if (!fsAuthorized(req, url, res)) return
1807
2208
  touchDevice(req)
1808
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2209
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1809
2210
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1810
2211
  const checked = fsRealChecked(resolved.abs)
1811
2212
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1833,7 +2234,7 @@ function fsUploadProbe(req, res, url) {
1833
2234
  }
1834
2235
 
1835
2236
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
1836
- function fsMkdir(req, res, url) {
2237
+ async function fsMkdir(req, res, url) {
1837
2238
  if (req.method !== 'POST') {
1838
2239
  res.writeHead(405, { allow: 'POST' })
1839
2240
  res.end()
@@ -1841,7 +2242,7 @@ function fsMkdir(req, res, url) {
1841
2242
  }
1842
2243
  if (!fsAuthorized(req, url, res)) return
1843
2244
  touchDevice(req)
1844
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2245
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1845
2246
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1846
2247
  const checked = fsRealChecked(resolved.abs)
1847
2248
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1996,7 +2397,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1996
2397
 
1997
2398
  /* POST /fs/upload-control?path&name&session&action=cancel
1998
2399
  * 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
1999
- function fsUploadControl(req, res, url) {
2400
+ async function fsUploadControl(req, res, url) {
2000
2401
  if (req.method !== 'POST') {
2001
2402
  res.writeHead(405, { allow: 'POST' })
2002
2403
  res.end()
@@ -2004,7 +2405,7 @@ function fsUploadControl(req, res, url) {
2004
2405
  }
2005
2406
  if (!fsAuthorized(req, url, res)) return
2006
2407
  touchDevice(req)
2007
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2408
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
2008
2409
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2009
2410
  const checked = fsRealChecked(resolved.abs)
2010
2411
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -2030,7 +2431,7 @@ function fsUploadControl(req, res, url) {
2030
2431
  }, 80)
2031
2432
  }
2032
2433
 
2033
- function serveFs(req, res, url) {
2434
+ async function serveFs(req, res, url) {
2034
2435
  const sub = url.pathname.slice('/fs'.length)
2035
2436
 
2036
2437
  // 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
@@ -2056,7 +2457,7 @@ function serveFs(req, res, url) {
2056
2457
  }
2057
2458
  if (!fsAuthorized(req, url, res)) return
2058
2459
  touchDevice(req)
2059
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2460
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
2060
2461
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2061
2462
  const checked = fsRealChecked(resolved.abs)
2062
2463
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -2317,10 +2718,10 @@ function lanAddresses() {
2317
2718
  return out
2318
2719
  }
2319
2720
 
2320
- const server = http.createServer((req, res) => {
2721
+ const server = http.createServer(async (req, res) => {
2321
2722
  try {
2322
2723
  const url = new URL(req.url, 'http://dsh-remote.local')
2323
- if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
2724
+ if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
2324
2725
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
2325
2726
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
2326
2727
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
@@ -2651,8 +3052,8 @@ server.listen(PORT, HOST, () => {
2651
3052
  }
2652
3053
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
2653
3054
  // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
2654
- startEventCollector('mux')
2655
- startEventCollector('host')
3055
+ eventCollectors.mux = startEventCollector('mux')
3056
+ eventCollectors.host = startEventCollector('host')
2656
3057
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
2657
3058
  setTimeout(() => checkForUpdates(false), 8000)
2658
3059
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)