dsh-remote-plugin 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gateway.cjs CHANGED
@@ -18,7 +18,8 @@
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_DEVICE_KEYS 独立设备密钥状态文件, 默认 ~/.dsh-remote/device-keys.json
22
+ * DSH_REMOTE_FS_ROOT 文件传输额外允许根, 默认 ~, 使用系统路径分隔符配置多根
22
23
  * DSH_REMOTE_FS_MAX_UPLOAD 上传字节上限, 默认 2147483648 (2GB)
23
24
  * DSH_REMOTE_WORKBENCH 工作台绑定文件, 默认 ~/.dsh-remote/workbench.json
24
25
  */
@@ -44,6 +45,12 @@ try {
44
45
  const ROOT = __dirname
45
46
  const PUBLIC_DIR = path.join(ROOT, 'public')
46
47
  const ANNOUNCEMENTS_FILE = process.env.DSH_REMOTE_ANNOUNCEMENTS_FILE || path.join(PUBLIC_DIR, 'announcements.json')
48
+ const DEFAULT_ANNOUNCEMENTS_URL = 'https://vm-0-2-ubuntu.tail1f6fc4.ts.net/announcements.json'
49
+ const ANNOUNCEMENTS_URL = process.env.DSH_REMOTE_ANNOUNCEMENTS_URL === undefined
50
+ ? DEFAULT_ANNOUNCEMENTS_URL
51
+ : String(process.env.DSH_REMOTE_ANNOUNCEMENTS_URL || '').trim()
52
+ const ANNOUNCEMENTS_CACHE_MS = durationEnv('DSH_REMOTE_ANNOUNCEMENTS_CACHE_MS', 15_000, 100, 10 * 60_000)
53
+ const ANNOUNCEMENTS_MAX_BYTES = 512 * 1024
47
54
  const PORT = Number(process.env.PORT) || 8787
48
55
  const HOST = process.env.HOST || '0.0.0.0'
49
56
 
@@ -71,9 +78,13 @@ const DSH_HEALTH_PATH = String(process.env.DSH_HEALTH_PATH || '/').startsWith('/
71
78
  : '/' + String(process.env.DSH_HEALTH_PATH)
72
79
  const TOKEN_FILE = process.env.TOKEN_FILE || path.join(os.homedir(), '.dsh-remote', 'token')
73
80
  const NOTES_FILE = process.env.DSH_REMOTE_NOTES || path.join(os.homedir(), '.dsh-remote', 'device-notes.json')
81
+ const DEVICE_KEYS_FILE = process.env.DSH_REMOTE_DEVICE_KEYS || path.join(os.homedir(), '.dsh-remote', 'device-keys.json')
74
82
  const WORKBENCH_FILE = process.env.DSH_REMOTE_WORKBENCH || path.join(os.homedir(), '.dsh-remote', 'workbench.json')
75
83
  const STARTED_AT = Date.now()
76
84
  const DSH_SERVICE = String(process.env.DSH_REMOTE_DSH_SERVICE || 'dsh-web').trim()
85
+ const SYSTEMCTL = String(process.env.DSH_REMOTE_SYSTEMCTL || 'systemctl').trim() || 'systemctl'
86
+ const DSH_CONTROL_TIMEOUT_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_TIMEOUT_MS', 45000, 2000, 5 * 60 * 1000)
87
+ const DSH_CONTROL_POLL_MS = durationEnv('DSH_REMOTE_DSH_CONTROL_POLL_MS', 500, 50, 5000)
77
88
  const HTTP_REQUEST_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_REQUEST_TIMEOUT_MS', 15 * 60 * 1000, 0, 24 * 60 * 60 * 1000)
78
89
  const HTTP_HEADERS_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_HEADERS_TIMEOUT_MS', 120000, 1000, 10 * 60 * 1000)
79
90
  const HTTP_KEEPALIVE_TIMEOUT_MS = durationEnv('GATEWAY_HTTP_KEEPALIVE_TIMEOUT_MS', 65000, 1000, 10 * 60 * 1000)
@@ -93,6 +104,17 @@ function gatewayVersion() {
93
104
  }
94
105
  }
95
106
  const VERSION = gatewayVersion()
107
+ const PROTOCOL_VERSION = 1
108
+ const CAPABILITIES = Object.freeze({
109
+ wsTicket: 1,
110
+ eventPolling: 1,
111
+ workspaceFiles: 2,
112
+ imagePromptTransport: 1,
113
+ dshLifecycle: 2,
114
+ centralAnnouncements: 2,
115
+ feedback: 1,
116
+ deviceKeys: 1,
117
+ })
96
118
 
97
119
  const MIME = {
98
120
  '.html': 'text/html; charset=utf-8',
@@ -120,8 +142,11 @@ const FS_ROOTS = (process.env.DSH_REMOTE_FS_ROOT || FS_DEFAULT_ROOT)
120
142
  .split(path.delimiter)
121
143
  .filter(Boolean)
122
144
  .map(r => path.resolve(r.trim() === '~' ? FS_DEFAULT_ROOT : r.trim()))
145
+ const FS_WORKSPACE_CACHE_MS = durationEnv('DSH_REMOTE_FS_WORKSPACE_CACHE_MS', 15_000, 1000, 10 * 60_000)
123
146
  const FS_MAX_UPLOAD = Number(process.env.DSH_REMOTE_FS_MAX_UPLOAD) || 2 * 1024 * 1024 * 1024
124
147
  let FS_ROOT_REALS = null
148
+ let fsWorkspaceRootsCache = { roots: [], reals: [], fetchedAt: 0 }
149
+ let fsWorkspaceRootsFetch = null
125
150
  function fsRootReals() {
126
151
  if (!FS_ROOT_REALS) {
127
152
  FS_ROOT_REALS = FS_ROOTS.map(r => { try { return fs.realpathSync(r) } catch { return null } }).filter(Boolean)
@@ -129,7 +154,7 @@ function fsRootReals() {
129
154
  return FS_ROOT_REALS
130
155
  }
131
156
  function fsInsideReal(real) {
132
- for (const root of fsRootReals()) {
157
+ for (const root of [...fsRootReals(), ...fsWorkspaceRootsCache.reals]) {
133
158
  if (real === root || real.startsWith(root + path.sep)) return true
134
159
  }
135
160
  return false
@@ -196,10 +221,118 @@ let TOKEN = loadToken()
196
221
  const WS_TICKET_TTL_MS = durationEnv('GATEWAY_WS_TICKET_TTL_MS', 90000, 10000, 10 * 60 * 1000)
197
222
  const wsTickets = new Map()
198
223
 
224
+ function newAccessToken() {
225
+ return crypto.randomBytes(24).toString('base64url')
226
+ }
227
+
228
+ function safeTokenEqual(left, right) {
229
+ const a = Buffer.from(String(left || ''))
230
+ const b = Buffer.from(String(right || ''))
231
+ return a.length === b.length && a.length > 0 && crypto.timingSafeEqual(a, b)
232
+ }
233
+
234
+ function normalizeDeviceKey(value) {
235
+ if (!value || typeof value !== 'object') return null
236
+ const id = String(value.id || '').replace(/[^A-Za-z0-9._~-]/g, '').slice(0, 96)
237
+ const accessToken = String(value.token || '').trim()
238
+ if (!id || accessToken.length < 16 || accessToken.length > 256) return null
239
+ return {
240
+ id,
241
+ note: String(value.note || '').trim().slice(0, 40),
242
+ token: accessToken,
243
+ createdAt: Number(value.createdAt) || Date.now(),
244
+ updatedAt: Number(value.updatedAt) || Number(value.createdAt) || Date.now(),
245
+ lastUsedAt: Number(value.lastUsedAt) || 0,
246
+ lastIp: String(value.lastIp || '').slice(0, 128),
247
+ lastKind: String(value.lastKind || '').slice(0, 24),
248
+ }
249
+ }
250
+
251
+ function loadDeviceKeys() {
252
+ try {
253
+ const parsed = JSON.parse(fs.readFileSync(DEVICE_KEYS_FILE, 'utf8'))
254
+ return {
255
+ enabled: parsed?.enabled === true,
256
+ keys: Array.isArray(parsed?.keys) ? parsed.keys.map(normalizeDeviceKey).filter(Boolean).slice(0, 100) : [],
257
+ }
258
+ } catch {
259
+ return { enabled: false, keys: [] }
260
+ }
261
+ }
262
+
263
+ const deviceKeyState = loadDeviceKeys()
264
+ let deviceKeysSaveTimer = null
265
+
266
+ function saveDeviceKeys() {
267
+ try {
268
+ fs.mkdirSync(path.dirname(DEVICE_KEYS_FILE), { recursive: true })
269
+ fs.writeFileSync(DEVICE_KEYS_FILE, JSON.stringify({ version: 1, ...deviceKeyState }, null, 2) + '\n', { mode: 0o600 })
270
+ try { fs.chmodSync(DEVICE_KEYS_FILE, 0o600) } catch {}
271
+ return true
272
+ } catch (err) {
273
+ console.warn('[device-keys] 保存失败: ' + (err?.message || err))
274
+ return false
275
+ }
276
+ }
277
+
278
+ function scheduleDeviceKeysSave() {
279
+ if (deviceKeysSaveTimer) return
280
+ deviceKeysSaveTimer = setTimeout(() => {
281
+ deviceKeysSaveTimer = null
282
+ saveDeviceKeys()
283
+ }, 500)
284
+ deviceKeysSaveTimer.unref?.()
285
+ }
286
+
287
+ function createDeviceKey(note = '') {
288
+ const now = Date.now()
289
+ const record = {
290
+ id: crypto.randomUUID?.() || crypto.randomBytes(16).toString('hex'),
291
+ note: String(note || '').trim().slice(0, 40) || '新设备',
292
+ token: newAccessToken(),
293
+ createdAt: now,
294
+ updatedAt: now,
295
+ lastUsedAt: 0,
296
+ lastIp: '',
297
+ lastKind: '',
298
+ }
299
+ deviceKeyState.keys.push(record)
300
+ if (!saveDeviceKeys()) {
301
+ deviceKeyState.keys.pop()
302
+ return null
303
+ }
304
+ return record
305
+ }
306
+
307
+ function deviceKeyViews() {
308
+ return deviceKeyState.keys.map(record => ({ ...record }))
309
+ }
310
+
311
+ function findDeviceKeyByToken(value) {
312
+ return deviceKeyState.keys.find(record => safeTokenEqual(value, record.token)) || null
313
+ }
314
+
315
+ function authKind(req) {
316
+ const marked = String(req.headers['x-dsh-remote-client'] || '')
317
+ return marked === 'app' || marked === 'web' || marked === 'admin' ? marked : kindOf(req)
318
+ }
319
+
320
+ function rememberDeviceKeyUse(record, req) {
321
+ if (!record) return
322
+ const now = Date.now()
323
+ const nextIp = ipOf(req)
324
+ const nextKind = authKind(req)
325
+ const needsSave = now - record.lastUsedAt > 30_000 || record.lastIp !== nextIp || record.lastKind !== nextKind
326
+ record.lastUsedAt = now
327
+ record.lastIp = nextIp
328
+ record.lastKind = nextKind
329
+ if (needsSave) scheduleDeviceKeysSave()
330
+ }
331
+
199
332
  /** 一键轮换令牌: 写回 TOKEN_FILE 并立即生效(旧令牌/旧连接全部失效)。 */
200
333
  function rotateToken() {
201
334
  if (TOKEN_FROM_ENV) return { error: 'token-from-env', detail: '令牌来自 TOKEN 环境变量, 请修改环境变量后重启' }
202
- const next = crypto.randomBytes(24).toString('base64url')
335
+ const next = newAccessToken()
203
336
  try {
204
337
  fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true })
205
338
  fs.writeFileSync(TOKEN_FILE, next + '\n', { mode: 0o600 })
@@ -219,11 +352,24 @@ function tokenOf(req, url) {
219
352
  }
220
353
 
221
354
  function authorized(req, url, options = {}) {
222
- if (tokenOf(req, url) === TOKEN) return true
355
+ const presented = tokenOf(req, url)
356
+ if (!deviceKeyState.enabled && safeTokenEqual(presented, TOKEN)) {
357
+ req.dshRemoteAuth = { type: 'shared', id: 'shared' }
358
+ return true
359
+ }
360
+ if (deviceKeyState.enabled) {
361
+ const record = findDeviceKeyByToken(presented)
362
+ if (record) {
363
+ req.dshRemoteAuth = { type: 'device', id: record.id }
364
+ rememberDeviceKeyUse(record, req)
365
+ return true
366
+ }
367
+ }
223
368
  if (options.consumeTicket) {
224
369
  const ticket = url.searchParams.get('ticket')
225
370
  const record = ticket && wsTickets.get(ticket)
226
371
  if (record && record.expiresAt > Date.now()) {
372
+ req.dshRemoteAuth = record.auth || { type: 'shared', id: 'shared' }
227
373
  record.uses--
228
374
  if (record.uses <= 0) wsTickets.delete(ticket)
229
375
  return true
@@ -233,13 +379,23 @@ function authorized(req, url, options = {}) {
233
379
  return false
234
380
  }
235
381
 
236
- function issueWsTicket() {
382
+ function adminAuthorized(req, url) {
383
+ const ok = safeTokenEqual(tokenOf(req, url), TOKEN)
384
+ if (ok) req.dshRemoteAuth = { type: 'admin', id: 'admin' }
385
+ return ok
386
+ }
387
+
388
+ function controlAuthorized(req, url) {
389
+ return adminAuthorized(req, url) || authorized(req, url)
390
+ }
391
+
392
+ function issueWsTicket(auth) {
237
393
  const now = Date.now()
238
394
  for (const [ticket, record] of wsTickets) {
239
395
  if (record.expiresAt <= now) wsTickets.delete(ticket)
240
396
  }
241
397
  const ticket = crypto.randomBytes(24).toString('base64url')
242
- wsTickets.set(ticket, { expiresAt: now + WS_TICKET_TTL_MS, uses: 4 })
398
+ wsTickets.set(ticket, { expiresAt: now + WS_TICKET_TTL_MS, uses: 4, auth: auth || { type: 'shared', id: 'shared' } })
243
399
  return { ticket, expiresAt: now + WS_TICKET_TTL_MS }
244
400
  }
245
401
 
@@ -298,7 +454,7 @@ function touchDevice(req, extra = {}) {
298
454
  if (!d) {
299
455
  d = {
300
456
  id: deviceKey, ip, clientId, kind: kindOf(req), ua: '', firstSeen: Date.now(), lastSeen: 0,
301
- requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
457
+ credentialId: '', requests: 0, authFailures: 0, channels: {}, channelCounts: {}, sockets: new Set()
302
458
  }
303
459
  devices.set(deviceKey, d)
304
460
  }
@@ -314,6 +470,7 @@ function touchDevice(req, extra = {}) {
314
470
  d.channels[extra.closeChannel] = count > 0
315
471
  }
316
472
  if (extra.failedAuth) d.authFailures++
473
+ if (req.dshRemoteAuth?.type === 'device') d.credentialId = req.dshRemoteAuth.id
317
474
  const marked = req.headers['x-dsh-remote-client']
318
475
  if (marked) d.kind = marked
319
476
  const ua = String(req.headers['user-agent'] || '')
@@ -327,6 +484,7 @@ function deviceViews() {
327
484
  ip: d.ip,
328
485
  id: d.id,
329
486
  clientId: d.clientId || '',
487
+ credentialId: d.credentialId || '',
330
488
  note: deviceNotes[d.ip] || '',
331
489
  kind: d.kind,
332
490
  ua: d.ua,
@@ -357,6 +515,44 @@ function kickDevice(ip) {
357
515
  return n
358
516
  }
359
517
 
518
+ function kickCredential(credentialId) {
519
+ const targets = [...devices.values()].filter(d => d.credentialId === credentialId)
520
+ let n = 0
521
+ for (const d of targets) {
522
+ for (const sock of d.sockets) {
523
+ try { sock.destroy() } catch {}
524
+ n++
525
+ }
526
+ d.sockets.clear()
527
+ d.channels = {}
528
+ d.channelCounts = {}
529
+ }
530
+ return n
531
+ }
532
+
533
+ function kickRemoteClients() {
534
+ let n = 0
535
+ for (const d of devices.values()) {
536
+ if (d.kind === 'admin') continue
537
+ for (const sock of d.sockets) {
538
+ try { sock.destroy() } catch {}
539
+ n++
540
+ }
541
+ d.sockets.clear()
542
+ d.channels = {}
543
+ d.channelCounts = {}
544
+ }
545
+ return n
546
+ }
547
+
548
+ function deviceKeysPayload() {
549
+ return {
550
+ supported: true,
551
+ enabled: deviceKeyState.enabled,
552
+ entries: deviceKeyViews(),
553
+ }
554
+ }
555
+
360
556
  // ---------- GitHub/镜像 更新检查 ----------
361
557
  function parseVersion(v) {
362
558
  const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v || '').trim())
@@ -557,6 +753,10 @@ function execFileResult(file, args, timeout = 5000) {
557
753
  resolvePromise({
558
754
  ok: !error,
559
755
  code: error?.code ?? 0,
756
+ signal: error?.signal || '',
757
+ killed: error?.killed === true,
758
+ timedOut: error?.code === 'ETIMEDOUT' || (error?.killed === true && error?.signal === 'SIGTERM'),
759
+ error: String(error?.message || '').trim(),
560
760
  stdout: String(stdout || '').trim(),
561
761
  stderr: String(stderr || '').trim(),
562
762
  })
@@ -564,15 +764,227 @@ function execFileResult(file, args, timeout = 5000) {
564
764
  })
565
765
  }
566
766
 
767
+ function parseSystemdShow(output) {
768
+ const values = {}
769
+ for (const line of String(output || '').split(/\r?\n/)) {
770
+ const split = line.indexOf('=')
771
+ if (split > 0) values[line.slice(0, split)] = line.slice(split + 1)
772
+ }
773
+ return values
774
+ }
775
+
776
+ function classifySystemctlFailure(result) {
777
+ const detail = [result?.stderr, result?.stdout, result?.error].filter(Boolean).join(' · ').slice(0, 1000)
778
+ if (result?.timedOut) return { code: 'COMMAND_TIMEOUT', message: 'systemctl 命令执行超时', detail }
779
+ if (result?.code === 'ENOENT' || /ENOENT|not found/i.test(detail)) return { code: 'SYSTEMCTL_NOT_FOUND', message: '系统中找不到 systemctl', detail }
780
+ if (/Failed to connect to bus|No medium found|user bus|DBUS/i.test(detail)) return { code: 'SYSTEMD_UNAVAILABLE', message: '无法连接当前用户的 systemd 会话', detail }
781
+ if (/access denied|permission denied|not authorized|authentication is required/i.test(detail)) return { code: 'PERMISSION_DENIED', message: '当前用户无权控制 DSH 服务', detail }
782
+ return { code: 'COMMAND_FAILED', message: 'systemctl 未能接受 DSH 控制命令', detail }
783
+ }
784
+
567
785
  async function dshServiceStatus() {
568
786
  if (process.platform === 'win32') {
569
- return { ok: true, supported: false, running: false, service: DSH_SERVICE, message: 'Windows 请配置 DSH_REMOTE_DSH_SERVICE 后接入任务计划程序' }
787
+ return { ok: true, supported: false, running: false, service: DSH_SERVICE, code: 'PLATFORM_UNSUPPORTED', message: 'Windows 暂不支持通过 systemd 远程控制 DSH' }
570
788
  }
571
789
  if (!/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
572
- return { ok: false, supported: false, running: false, service: DSH_SERVICE, message: '服务名配置不合法' }
790
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, code: 'INVALID_SERVICE', message: 'DSH_REMOTE_DSH_SERVICE 服务名配置不合法' }
791
+ }
792
+ const r = await execFileResult(SYSTEMCTL, [
793
+ '--user', 'show', DSH_SERVICE,
794
+ '--property=Id,LoadState,ActiveState,SubState,UnitFileState,MainPID,Result,ExecMainStatus',
795
+ '--no-pager'
796
+ ], 5000)
797
+ if (!r.ok) {
798
+ const failure = classifySystemctlFailure(r)
799
+ return { ok: false, supported: false, running: false, service: DSH_SERVICE, ...failure }
800
+ }
801
+ const value = parseSystemdShow(r.stdout)
802
+ const loadState = value.LoadState || 'unknown'
803
+ const activeState = value.ActiveState || 'unknown'
804
+ const subState = value.SubState || 'unknown'
805
+ const mainPid = Number(value.MainPID) || 0
806
+ if (loadState === 'not-found') {
807
+ return {
808
+ ok: true, supported: false, running: false, service: DSH_SERVICE,
809
+ code: 'SERVICE_NOT_FOUND', message: `未找到 systemd 用户服务 ${DSH_SERVICE}`,
810
+ loadState, activeState, subState, mainPid,
811
+ }
812
+ }
813
+ return {
814
+ ok: true,
815
+ supported: true,
816
+ running: activeState === 'active' && (subState === 'running' || subState === 'exited'),
817
+ service: value.Id || DSH_SERVICE,
818
+ state: activeState,
819
+ loadState,
820
+ activeState,
821
+ subState,
822
+ unitFileState: value.UnitFileState || '',
823
+ mainPid,
824
+ result: value.Result || '',
825
+ execMainStatus: Number(value.ExecMainStatus) || 0,
826
+ }
827
+ }
828
+
829
+ function delay(ms) {
830
+ return new Promise(resolvePromise => setTimeout(resolvePromise, ms))
831
+ }
832
+
833
+ async function probeDshUpstream() {
834
+ const startedAt = Date.now()
835
+ try {
836
+ const probe = await fetch(new URL(DSH_HEALTH_PATH, UPSTREAM), {
837
+ signal: AbortSignal.timeout(Math.min(2500, UPSTREAM_REQUEST_TIMEOUT_MS)),
838
+ cache: 'no-store',
839
+ })
840
+ return {
841
+ ok: probe.ok,
842
+ reachable: true,
843
+ status: probe.status,
844
+ elapsedMs: Date.now() - startedAt,
845
+ error: probe.ok ? '' : `DSH HTTP ${probe.status}`,
846
+ }
847
+ } catch (err) {
848
+ return { ok: false, reachable: false, status: 0, elapsedMs: Date.now() - startedAt, error: String(err?.message || err || '连接失败').slice(0, 500) }
849
+ }
850
+ }
851
+
852
+ let dshControlOperation = null
853
+
854
+ function dshOperationStep(operation, stage, message, extra = {}) {
855
+ const now = Date.now()
856
+ operation.stage = stage
857
+ operation.message = message
858
+ operation.updatedAt = now
859
+ Object.assign(operation, extra)
860
+ if (operation.done) operation.elapsedMs = now - operation.startedAt
861
+ operation.steps.push({ stage, message, at: now, elapsedMs: now - operation.startedAt })
862
+ }
863
+
864
+ function failDshOperation(operation, code, message, detail = '', status = null) {
865
+ dshOperationStep(operation, 'failed', message, {
866
+ ok: false,
867
+ done: true,
868
+ code,
869
+ detail: String(detail || '').slice(0, 1000),
870
+ ...(status ? { status } : {}),
871
+ })
872
+ }
873
+
874
+ function dshEventChannelStatus() {
875
+ const pick = kind => ({
876
+ connected: eventCollectorState[kind].connected,
877
+ attempt: eventCollectorState[kind].attempt,
878
+ lastError: eventCollectorState[kind].lastError,
879
+ })
880
+ const mux = pick('mux')
881
+ const host = pick('host')
882
+ return { ok: mux.connected && host.connected, mux, host }
883
+ }
884
+
885
+ function reconnectDshEventCollectors() {
886
+ eventCollectors.mux?.reconnectNow()
887
+ eventCollectors.host?.reconnectNow()
888
+ }
889
+
890
+ async function runDshControlOperation(operation) {
891
+ try {
892
+ dshOperationStep(operation, 'checking', `正在检查 systemd 用户服务 ${DSH_SERVICE}`)
893
+ const initial = await dshServiceStatus()
894
+ operation.initialStatus = initial
895
+ if (!initial.supported) {
896
+ failDshOperation(operation, initial.code || 'UNSUPPORTED', initial.message || '当前 DSH 服务不可控', initial.detail, initial)
897
+ return
898
+ }
899
+ if (operation.action === 'start' && initial.running) {
900
+ dshOperationStep(operation, 'complete', `DSH 已在运行(${initial.service},PID ${initial.mainPid || '未知'})`, {
901
+ ok: true, done: true, code: 'ALREADY_RUNNING', status: initial, upstream: await probeDshUpstream(),
902
+ })
903
+ return
904
+ }
905
+
906
+ dshOperationStep(operation, 'command', `正在向 systemd 提交 DSH ${operation.action === 'start' ? '启动' : '重启'}命令`)
907
+ const command = await execFileResult(SYSTEMCTL, ['--user', '--no-block', operation.action, DSH_SERVICE], 5000)
908
+ operation.command = { ok: command.ok, code: command.code, signal: command.signal }
909
+ if (!command.ok) {
910
+ const failure = classifySystemctlFailure(command)
911
+ failDshOperation(operation, failure.code, failure.message, failure.detail, await dshServiceStatus())
912
+ return
913
+ }
914
+
915
+ dshOperationStep(operation, 'waiting-service', `命令已接受,正在等待 ${initial.service} 进入运行状态`)
916
+ const initialPid = initial.mainPid || 0
917
+ let restartObserved = operation.action === 'start' || !initial.running || initialPid <= 0
918
+ let waitingUpstreamReported = false
919
+ let waitingEventsReported = false
920
+ let lastEventReconnectAt = 0
921
+ let lastStatus = initial
922
+ let lastProbe = null
923
+ const deadline = Date.now() + DSH_CONTROL_TIMEOUT_MS
924
+ while (Date.now() < deadline) {
925
+ const status = await dshServiceStatus()
926
+ lastStatus = status
927
+ operation.status = status
928
+ if (!status.supported) {
929
+ failDshOperation(operation, status.code || 'STATUS_FAILED', status.message || '无法读取 DSH 服务状态', status.detail, status)
930
+ return
931
+ }
932
+ if (operation.action === 'restart' && (status.mainPid > 0 && status.mainPid !== initialPid || status.activeState !== 'active')) restartObserved = true
933
+ if (status.activeState === 'failed') {
934
+ failDshOperation(operation, 'SERVICE_FAILED', `DSH 服务进入 failed 状态(Result=${status.result || 'unknown'},ExecMainStatus=${status.execMainStatus})`, '', status)
935
+ return
936
+ }
937
+ if (status.running && restartObserved) {
938
+ if (!waitingUpstreamReported) {
939
+ waitingUpstreamReported = true
940
+ dshOperationStep(operation, 'waiting-upstream', `服务进程已运行(PID ${status.mainPid || '未知'}),正在等待 DSH HTTP 接口 ${UPSTREAM.origin}${DSH_HEALTH_PATH} 恢复`)
941
+ }
942
+ lastProbe = await probeDshUpstream()
943
+ operation.upstream = lastProbe
944
+ if (lastProbe.ok) {
945
+ if (!waitingEventsReported) {
946
+ waitingEventsReported = true
947
+ dshOperationStep(operation, 'waiting-events', `DSH HTTP 已恢复(${lastProbe.status}),正在连接 mux/host 实时消息通道`)
948
+ }
949
+ if (Date.now() - lastEventReconnectAt >= 1500) {
950
+ lastEventReconnectAt = Date.now()
951
+ reconnectDshEventCollectors()
952
+ }
953
+ const events = dshEventChannelStatus()
954
+ operation.events = events
955
+ if (events.ok) {
956
+ dshOperationStep(operation, 'complete', `DSH ${operation.action === 'start' ? '启动' : '重启'}成功:服务已运行,HTTP ${lastProbe.status},实时通道已连接,PID ${status.mainPid || '未知'}`, {
957
+ ok: true, done: true, code: 'SUCCESS', status, upstream: lastProbe, events,
958
+ })
959
+ return
960
+ }
961
+ }
962
+ }
963
+ await delay(DSH_CONTROL_POLL_MS)
964
+ }
965
+ if (!lastStatus.running || !restartObserved) {
966
+ const reason = operation.action === 'restart' && !restartObserved
967
+ ? `未观察到 ${initial.service} 进程完成重启(初始 PID ${initialPid || '未知'},当前 PID ${lastStatus.mainPid || '未知'})`
968
+ : `${initial.service} 未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内进入运行状态(${lastStatus.activeState}/${lastStatus.subState})`
969
+ failDshOperation(operation, 'SERVICE_TIMEOUT', reason, '', lastStatus)
970
+ return
971
+ }
972
+ if (lastProbe?.ok) {
973
+ const events = dshEventChannelStatus()
974
+ failDshOperation(
975
+ operation,
976
+ 'EVENTS_TIMEOUT',
977
+ `DSH 服务和 HTTP 已恢复,但 mux/host 实时消息通道未在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内连接`,
978
+ ['mux', 'host'].map(kind => `${kind}: ${events[kind].connected ? 'connected' : events[kind].lastError || `retry ${events[kind].attempt}`}`).join(' · '),
979
+ lastStatus,
980
+ )
981
+ operation.events = events
982
+ return
983
+ }
984
+ failDshOperation(operation, 'UPSTREAM_TIMEOUT', `服务进程已运行,但 DSH HTTP 接口在 ${Math.round(DSH_CONTROL_TIMEOUT_MS / 1000)} 秒内未恢复`, lastProbe?.error || '', lastStatus)
985
+ } catch (err) {
986
+ failDshOperation(operation, 'INTERNAL_ERROR', 'DSH 控制流程发生未预期错误', String(err?.stack || err))
573
987
  }
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
988
  }
577
989
 
578
990
  async function serveDshControl(req, res, url) {
@@ -582,7 +994,7 @@ async function serveDshControl(req, res, url) {
582
994
  res.end()
583
995
  return
584
996
  }
585
- if (!authorized(req, url)) {
997
+ if (!controlAuthorized(req, url)) {
586
998
  authFailures++
587
999
  touchDevice(req, { failedAuth: true })
588
1000
  cors(res)
@@ -592,9 +1004,26 @@ async function serveDshControl(req, res, url) {
592
1004
  }
593
1005
  touchDevice(req, { kind: 'admin' })
594
1006
  if (req.method === 'GET') {
1007
+ const operationId = String(url.searchParams.get('operation') || '').trim()
595
1008
  cors(res)
1009
+ if (operationId) {
1010
+ if (!dshControlOperation || dshControlOperation.operationId !== operationId) {
1011
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1012
+ res.end(JSON.stringify({ ok: false, done: true, code: 'OPERATION_NOT_FOUND', error: '找不到该 DSH 控制操作,网关可能已重启' }))
1013
+ return
1014
+ }
1015
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1016
+ res.end(JSON.stringify({
1017
+ ...dshControlOperation,
1018
+ elapsedMs: dshControlOperation.done
1019
+ ? dshControlOperation.elapsedMs
1020
+ : Date.now() - dshControlOperation.startedAt,
1021
+ }))
1022
+ return
1023
+ }
596
1024
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
597
- res.end(JSON.stringify(await dshServiceStatus()))
1025
+ const status = await dshServiceStatus()
1026
+ res.end(JSON.stringify({ ...status, operation: dshControlOperation && !dshControlOperation.done ? dshControlOperation : null }))
598
1027
  return
599
1028
  }
600
1029
  if (req.method !== 'POST') {
@@ -604,25 +1033,36 @@ async function serveDshControl(req, res, url) {
604
1033
  return
605
1034
  }
606
1035
  let body = {}
607
- try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch {}
1036
+ try { body = JSON.parse((await readBody(req, 4096)) || '{}') } catch (err) {
1037
+ cors(res)
1038
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1039
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_JSON', error: '请求体不是有效 JSON', detail: String(err?.message || err) }))
1040
+ return
1041
+ }
608
1042
  const action = body?.action
609
1043
  if (action !== 'start' && action !== 'restart') {
610
1044
  cors(res)
611
1045
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
612
- res.end(JSON.stringify({ ok: false, error: 'action 必须是 start 或 restart' }))
1046
+ res.end(JSON.stringify({ ok: false, code: 'INVALID_ACTION', error: 'action 必须是 start 或 restart' }))
613
1047
  return
614
1048
  }
615
- if (process.platform === 'win32' || !/^[A-Za-z0-9_.@-]+$/.test(DSH_SERVICE)) {
1049
+ if (dshControlOperation && !dshControlOperation.done) {
616
1050
  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 }))
1051
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
1052
+ res.end(JSON.stringify({ ok: false, code: 'OPERATION_IN_PROGRESS', error: '已有 DSH 控制操作正在执行', operation: dshControlOperation }))
619
1053
  return
620
1054
  }
621
- const r = await execFileResult('systemctl', ['--user', action, DSH_SERVICE], 10000)
622
- const status = await dshServiceStatus()
1055
+ const now = Date.now()
1056
+ dshControlOperation = {
1057
+ operationId: crypto.randomUUID(), action, service: DSH_SERVICE,
1058
+ ok: false, accepted: true, done: false, stage: 'queued', code: 'ACCEPTED',
1059
+ message: `已接收 DSH ${action === 'start' ? '启动' : '重启'}请求,等待检查服务`,
1060
+ startedAt: now, updatedAt: now, steps: [],
1061
+ }
1062
+ setImmediate(() => { void runDshControlOperation(dshControlOperation) })
623
1063
  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 || '' }))
1064
+ res.writeHead(202, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1065
+ res.end(JSON.stringify(dshControlOperation))
626
1066
  }
627
1067
 
628
1068
  // ---------- 事件轮询缓冲 ----------
@@ -640,6 +1080,7 @@ const eventCollectorState = {
640
1080
  mux: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
641
1081
  host: { connected: false, lastEventAt: 0, lastConnectAt: 0, reconnects: 0, lastError: '', lastCloseCode: 0, lastCloseReason: '', attempt: 0, clients: 0, framesBroadcast: 0, lastBroadcastAt: 0 },
642
1082
  }
1083
+ const eventCollectors = { mux: null, host: null }
643
1084
 
644
1085
  /** 递归截断超大字段,避免单条超大事件撑爆环形缓冲。 */
645
1086
  function truncateEventValue(v, depth = 0) {
@@ -741,7 +1182,7 @@ function serveWsTicket(req, res, url) {
741
1182
  }
742
1183
  cors(res, req)
743
1184
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
744
- res.end(JSON.stringify({ ok: true, ...issueWsTicket() }))
1185
+ res.end(JSON.stringify({ ok: true, ...issueWsTicket(req.dshRemoteAuth) }))
745
1186
  }
746
1187
 
747
1188
  function serveEventPoll(req, res, url) {
@@ -876,6 +1317,13 @@ function startEventCollector(kind) {
876
1317
  connect()
877
1318
  return {
878
1319
  kind,
1320
+ reconnectNow() {
1321
+ if (stopped || state.connected || ws?.readyState === 0) return
1322
+ clearTimeout(retryTimer)
1323
+ retryTimer = null
1324
+ state.attempt = 0
1325
+ connect()
1326
+ },
879
1327
  close() {
880
1328
  stopped = true
881
1329
  clearTimeout(retryTimer)
@@ -1000,15 +1448,106 @@ function maskIp(ip) {
1000
1448
  return s
1001
1449
  }
1002
1450
 
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])
1451
+ let announcementsCache = null
1452
+ let announcementsFetch = null
1453
+
1454
+ function parseAnnouncements(raw) {
1455
+ if (Buffer.byteLength(raw, 'utf8') > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1456
+ const data = JSON.parse(raw)
1457
+ const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
1458
+ if (items.length > 200 || items.some(item => !item || typeof item !== 'object' || Array.isArray(item))) {
1459
+ throw new Error('invalid announcements payload')
1460
+ }
1461
+ return { data: Array.isArray(data) ? { items: data } : data, items }
1462
+ }
1463
+
1464
+ function localAnnouncements() {
1465
+ try {
1466
+ const raw = fs.readFileSync(ANNOUNCEMENTS_FILE, 'utf8')
1467
+ const parsed = parseAnnouncements(raw)
1468
+ return { ...parsed, raw: JSON.stringify(parsed.data), source: 'local', stale: false, fetchedAt: Date.now() }
1469
+ } catch {
1470
+ const data = { items: [] }
1471
+ return { data, items: data.items, raw: JSON.stringify(data), source: 'empty', stale: false, fetchedAt: Date.now() }
1472
+ }
1473
+ }
1474
+
1475
+ function safeAnnouncementsUrl(value) {
1476
+ const target = new URL(value)
1477
+ const loopback = ['127.0.0.1', '::1', 'localhost'].includes(target.hostname)
1478
+ if (target.protocol !== 'https:' && !(target.protocol === 'http:' && loopback)) {
1479
+ throw new Error('central announcements URL must use HTTPS')
1480
+ }
1481
+ return target.href
1482
+ }
1483
+
1484
+ async function loadCentralAnnouncements(force = false) {
1485
+ const now = Date.now()
1486
+ if (!ANNOUNCEMENTS_URL) return localAnnouncements()
1487
+ if (!force && announcementsCache && now - announcementsCache.fetchedAt < ANNOUNCEMENTS_CACHE_MS) return announcementsCache
1488
+ if (announcementsFetch) return announcementsFetch
1489
+ announcementsFetch = (async () => {
1490
+ try {
1491
+ const headers = { accept: 'application/json' }
1492
+ if (announcementsCache?.etag) headers['if-none-match'] = announcementsCache.etag
1493
+ const res = await fetch(safeAnnouncementsUrl(ANNOUNCEMENTS_URL), {
1494
+ headers,
1495
+ cache: 'no-store',
1496
+ redirect: 'follow',
1497
+ signal: AbortSignal.timeout(8000),
1498
+ })
1499
+ safeAnnouncementsUrl(res.url)
1500
+ if (res.status === 304 && announcementsCache) {
1501
+ announcementsCache = { ...announcementsCache, fetchedAt: now, stale: false }
1502
+ return announcementsCache
1503
+ }
1504
+ if (!res.ok) throw new Error(`central announcements HTTP ${res.status}`)
1505
+ const declared = Number(res.headers.get('content-length') || 0)
1506
+ if (declared > ANNOUNCEMENTS_MAX_BYTES) throw new Error('announcements too large')
1507
+ const raw = await res.text()
1508
+ const parsed = parseAnnouncements(raw)
1509
+ announcementsCache = {
1510
+ ...parsed,
1511
+ raw: JSON.stringify(parsed.data),
1512
+ source: 'central',
1513
+ stale: false,
1514
+ fetchedAt: now,
1515
+ etag: String(res.headers.get('etag') || ''),
1516
+ }
1517
+ return announcementsCache
1518
+ } catch (err) {
1519
+ if (announcementsCache?.source === 'central') {
1520
+ return { ...announcementsCache, stale: true, error: String(err?.message || err) }
1521
+ }
1522
+ return { ...localAnnouncements(), error: String(err?.message || err) }
1523
+ } finally {
1524
+ announcementsFetch = null
1525
+ }
1526
+ })()
1527
+ return announcementsFetch
1528
+ }
1529
+
1530
+ async function serveAnnouncements(req, res) {
1531
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1532
+ res.writeHead(405, { allow: 'GET, HEAD' })
1533
+ res.end()
1534
+ return
1535
+ }
1536
+ const snapshot = await loadCentralAnnouncements()
1537
+ cors(res)
1538
+ res.writeHead(200, {
1539
+ 'content-type': 'application/json; charset=utf-8',
1540
+ 'content-length': Buffer.byteLength(snapshot.raw),
1541
+ 'cache-control': 'no-store',
1542
+ 'x-content-type-options': 'nosniff',
1543
+ 'x-dsh-announcements-source': snapshot.source,
1544
+ ...(snapshot.stale ? { warning: '110 - "Response is stale"' } : {}),
1545
+ })
1546
+ if (req.method === 'HEAD') res.end()
1547
+ else res.end(snapshot.raw)
1548
+ }
1549
+
1550
+ function findPollVote(items, announcementId, pollId, optionId) {
1012
1551
  const announcement = items.find(item => String(item?.id || '').trim() === announcementId)
1013
1552
  const poll = announcement?.poll
1014
1553
  if (!poll || String(poll.id || '').trim() !== pollId || !Array.isArray(poll.options)) return { error: 'poll not found' }
@@ -1019,6 +1558,22 @@ function validatePollVote(payload) {
1019
1558
  return { announcementId, pollId, optionId, optionLabel }
1020
1559
  }
1021
1560
 
1561
+ async function validatePollVote(payload) {
1562
+ const announcementId = String(payload.announcementId || '').trim()
1563
+ const pollId = String(payload.pollId || '').trim()
1564
+ const optionId = String(payload.optionId || '').trim()
1565
+ if (!announcementId || !pollId || !optionId) return { error: 'poll fields required' }
1566
+ if (announcementId.length > 120 || pollId.length > 120 || optionId.length > 120) return { error: 'poll fields too long' }
1567
+ let snapshot = await loadCentralAnnouncements()
1568
+ let result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1569
+ // 中央公告刚发布、网关缓存尚未到期时,投票请求触发一次强制刷新,避免出现公告可见但选项暂不可投。
1570
+ if (result.error && ANNOUNCEMENTS_URL) {
1571
+ snapshot = await loadCentralAnnouncements(true)
1572
+ result = findPollVote(snapshot.items, announcementId, pollId, optionId)
1573
+ }
1574
+ return result
1575
+ }
1576
+
1022
1577
  function serveFeedback(req, res, url) {
1023
1578
  cors(res)
1024
1579
  if (req.method === 'OPTIONS') {
@@ -1042,7 +1597,7 @@ function serveFeedback(req, res, url) {
1042
1597
 
1043
1598
  let body = ''
1044
1599
  req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy() })
1045
- req.on('end', () => {
1600
+ req.on('end', async () => {
1046
1601
  let payload
1047
1602
  try {
1048
1603
  payload = JSON.parse(body || '{}')
@@ -1060,7 +1615,7 @@ function serveFeedback(req, res, url) {
1060
1615
  res.end(JSON.stringify({ error: 'invalid type', expect: 'bug|suggestion|other|poll' }))
1061
1616
  return
1062
1617
  }
1063
- const pollVote = type === 'poll' ? validatePollVote(payload) : null
1618
+ const pollVote = type === 'poll' ? await validatePollVote(payload) : null
1064
1619
  if (pollVote?.error) {
1065
1620
  res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1066
1621
  res.end(JSON.stringify({ error: pollVote.error }))
@@ -1147,21 +1702,7 @@ function serveStatic(req, res, url) {
1147
1702
  if (pathname === '/') pathname = '/index.html'
1148
1703
  if (pathname === '/admin') pathname = '/admin.html'
1149
1704
  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
- })
1705
+ void serveAnnouncements(req, res)
1165
1706
  return
1166
1707
  }
1167
1708
  // 兼容旧版 App(版本比较不认 -rc): 无 local 参数的请求把 0.5.2-rc.1 显示为 0.5.2,
@@ -1240,7 +1781,7 @@ function serveAdminApi(req, res, url) {
1240
1781
  const sub = url.pathname.slice('/admin/api'.length) || '/'
1241
1782
  if (sub === '/dsh') return serveDshControl(req, res, url)
1242
1783
  if (sub === '/state' && req.method === 'GET') {
1243
- if (!authorized(req, url)) {
1784
+ if (!adminAuthorized(req, url)) {
1244
1785
  authFailures++
1245
1786
  touchDevice(req, { failedAuth: true })
1246
1787
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1254,12 +1795,15 @@ function serveAdminApi(req, res, url) {
1254
1795
  mode: 'gateway',
1255
1796
  version: VERSION,
1256
1797
  pid: process.pid,
1798
+ platform: process.platform,
1257
1799
  hostname: os.hostname(),
1258
1800
  lanIPs: lanAddresses(),
1259
1801
  startedAt: STARTED_AT,
1260
1802
  uptimeSec: Math.round((Date.now() - STARTED_AT) / 1000),
1261
1803
  host: HOST,
1262
1804
  port: PORT,
1805
+ protocol: { version: PROTOCOL_VERSION },
1806
+ capabilities: CAPABILITIES,
1263
1807
  upstream: { url: UPSTREAM.origin, reachable },
1264
1808
  latest: {
1265
1809
  version: latestState.version,
@@ -1273,17 +1817,106 @@ function serveAdminApi(req, res, url) {
1273
1817
  tokenFromEnv: TOKEN_FROM_ENV,
1274
1818
  tokenMasked: TOKEN.slice(0, 4) + '…' + TOKEN.slice(-4),
1275
1819
  tokenLength: TOKEN.length,
1820
+ deviceKeys: deviceKeysPayload(),
1276
1821
  totalRequests,
1277
1822
  authFailures,
1278
1823
  deviceCount: devices.size,
1279
1824
  onlineCount: [...devices.values()].filter(d => Date.now() - d.lastSeen < 60_000).length,
1825
+ events: eventCollectorState,
1280
1826
  devices: deviceViews()
1281
1827
  }))
1282
1828
  })
1283
1829
  return
1284
1830
  }
1831
+ if (sub.startsWith('/device-keys/') && req.method === 'POST') {
1832
+ if (!adminAuthorized(req, url)) {
1833
+ authFailures++
1834
+ touchDevice(req, { failedAuth: true })
1835
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1836
+ res.end(JSON.stringify({ error: 'unauthorized' }))
1837
+ return
1838
+ }
1839
+ let body = ''
1840
+ req.on('data', chunk => {
1841
+ body += chunk
1842
+ if (body.length > 8192) req.destroy()
1843
+ })
1844
+ req.on('end', () => {
1845
+ let payload = {}
1846
+ try { payload = JSON.parse(body || '{}') } catch {
1847
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
1848
+ res.end(JSON.stringify({ error: 'bad-request', detail: '请求内容不是有效 JSON' }))
1849
+ return
1850
+ }
1851
+ const send = (status, value) => {
1852
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
1853
+ res.end(JSON.stringify(value))
1854
+ }
1855
+ if (sub === '/device-keys/mode') {
1856
+ if (typeof payload.enabled !== 'boolean') return send(400, { error: 'bad-request', detail: 'enabled 必须是布尔值' })
1857
+ if (payload.enabled && deviceKeyState.keys.length === 0 && !createDeviceKey(payload.note || '我的设备')) {
1858
+ return send(500, { error: 'write-failed', detail: '无法创建首个设备密钥' })
1859
+ }
1860
+ const previous = deviceKeyState.enabled
1861
+ deviceKeyState.enabled = payload.enabled
1862
+ if (!saveDeviceKeys()) {
1863
+ deviceKeyState.enabled = previous
1864
+ return send(500, { error: 'write-failed', detail: '无法保存独立设备密钥设置' })
1865
+ }
1866
+ wsTickets.clear()
1867
+ const disconnected = kickRemoteClients()
1868
+ return send(200, { ok: true, disconnected, deviceKeys: deviceKeysPayload() })
1869
+ }
1870
+ if (sub === '/device-keys/create') {
1871
+ if (deviceKeyState.keys.length >= 100) return send(409, { error: 'too-many-device-keys', detail: '设备密钥数量已达到上限' })
1872
+ const record = createDeviceKey(payload.note)
1873
+ return record
1874
+ ? send(201, { ok: true, entry: { ...record }, deviceKeys: deviceKeysPayload() })
1875
+ : send(500, { error: 'write-failed', detail: '无法保存设备密钥' })
1876
+ }
1877
+ const id = String(payload.id || '').trim()
1878
+ const index = deviceKeyState.keys.findIndex(record => record.id === id)
1879
+ if (index < 0) return send(404, { error: 'device-key-not-found', detail: '找不到该设备密钥' })
1880
+ const record = deviceKeyState.keys[index]
1881
+ if (sub === '/device-keys/note') {
1882
+ const previous = { note: record.note, updatedAt: record.updatedAt }
1883
+ record.note = String(payload.note || '').trim().slice(0, 40)
1884
+ record.updatedAt = Date.now()
1885
+ if (!saveDeviceKeys()) {
1886
+ Object.assign(record, previous)
1887
+ return send(500, { error: 'write-failed', detail: '无法保存备注' })
1888
+ }
1889
+ return send(200, { ok: true, entry: { ...record } })
1890
+ }
1891
+ if (sub === '/device-keys/rotate') {
1892
+ const previous = { token: record.token, updatedAt: record.updatedAt, lastUsedAt: record.lastUsedAt }
1893
+ record.token = newAccessToken()
1894
+ record.updatedAt = Date.now()
1895
+ record.lastUsedAt = 0
1896
+ if (!saveDeviceKeys()) {
1897
+ Object.assign(record, previous)
1898
+ return send(500, { error: 'write-failed', detail: '无法轮换设备令牌' })
1899
+ }
1900
+ wsTickets.clear()
1901
+ const disconnected = kickCredential(record.id)
1902
+ return send(200, { ok: true, disconnected, entry: { ...record } })
1903
+ }
1904
+ if (sub === '/device-keys/revoke') {
1905
+ deviceKeyState.keys.splice(index, 1)
1906
+ if (!saveDeviceKeys()) {
1907
+ deviceKeyState.keys.splice(index, 0, record)
1908
+ return send(500, { error: 'write-failed', detail: '无法退出设备' })
1909
+ }
1910
+ wsTickets.clear()
1911
+ const disconnected = kickCredential(record.id)
1912
+ return send(200, { ok: true, disconnected, id: record.id })
1913
+ }
1914
+ return send(404, { error: 'not-found' })
1915
+ })
1916
+ return
1917
+ }
1285
1918
  if (sub === '/token/rotate' && req.method === 'POST') {
1286
- if (!authorized(req, url)) {
1919
+ if (!adminAuthorized(req, url)) {
1287
1920
  authFailures++
1288
1921
  touchDevice(req, { failedAuth: true })
1289
1922
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1297,16 +1930,14 @@ function serveAdminApi(req, res, url) {
1297
1930
  return
1298
1931
  }
1299
1932
  // 旧令牌立即失效: 断开已连接的 App/浏览器, 让它们重新扫码/输入
1300
- for (const d of devices.values()) {
1301
- if (d.kind !== 'admin') kickDevice(d.ip)
1302
- }
1933
+ kickRemoteClients()
1303
1934
  touchDevice(req)
1304
1935
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
1305
1936
  res.end(JSON.stringify({ ok: true, token: r.token, tokenMasked: r.token.slice(0, 4) + '…' + r.token.slice(-4) }))
1306
1937
  return
1307
1938
  }
1308
1939
  if (sub === '/shutdown' && req.method === 'POST') {
1309
- if (!authorized(req, url)) {
1940
+ if (!adminAuthorized(req, url)) {
1310
1941
  authFailures++
1311
1942
  touchDevice(req, { failedAuth: true })
1312
1943
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
@@ -1323,7 +1954,7 @@ function serveAdminApi(req, res, url) {
1323
1954
  return
1324
1955
  }
1325
1956
  if (sub === '/note' && req.method === 'POST') {
1326
- if (!authorized(req, url)) {
1957
+ if (!adminAuthorized(req, url)) {
1327
1958
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1328
1959
  res.end(JSON.stringify({ error: 'unauthorized' }))
1329
1960
  return
@@ -1348,7 +1979,7 @@ function serveAdminApi(req, res, url) {
1348
1979
  return
1349
1980
  }
1350
1981
  if (sub === '/kick' && req.method === 'POST') {
1351
- if (!authorized(req, url)) {
1982
+ if (!adminAuthorized(req, url)) {
1352
1983
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
1353
1984
  res.end(JSON.stringify({ error: 'unauthorized' }))
1354
1985
  return
@@ -1390,17 +2021,61 @@ function fsAuthorized(req, url, res) {
1390
2021
  return true
1391
2022
  }
1392
2023
 
1393
- /** 把用户给的 path 解析为绝对路径并做词法根检查; 返回 {abs} {error}。 */
1394
- function fsResolve(input) {
2024
+ function fsInsideRoot(abs, root) {
2025
+ return abs === root || abs.startsWith(root + path.sep)
2026
+ }
2027
+
2028
+ function fsWorkspacePath(value) {
2029
+ const raw = String(value?.path || value?.cwd || value?.root || '').trim()
2030
+ if (!raw || !path.isAbsolute(raw)) return ''
2031
+ return path.resolve(raw)
2032
+ }
2033
+
2034
+ async function loadFsWorkspaceRoots(force = false) {
2035
+ const now = Date.now()
2036
+ if (!force && now - fsWorkspaceRootsCache.fetchedAt < FS_WORKSPACE_CACHE_MS) return fsWorkspaceRootsCache
2037
+ if (fsWorkspaceRootsFetch) return fsWorkspaceRootsFetch
2038
+ fsWorkspaceRootsFetch = (async () => {
2039
+ try {
2040
+ const target = new URL('/api/workspace.list', UPSTREAM)
2041
+ const res = await fetch(target, {
2042
+ method: 'POST',
2043
+ headers: { 'content-type': 'application/json' },
2044
+ body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace.list', payload: {} }),
2045
+ signal: AbortSignal.timeout(Math.min(8000, UPSTREAM_REQUEST_TIMEOUT_MS)),
2046
+ })
2047
+ if (!res.ok) throw new Error(`workspace.list HTTP ${res.status}`)
2048
+ const body = await res.json()
2049
+ const value = body?.result?.ok ? body.result.value : null
2050
+ const items = Array.isArray(value?.items) ? value.items : []
2051
+ const roots = [...new Set(items.map(fsWorkspacePath).filter(Boolean))]
2052
+ const reals = roots.map(root => { try { return fs.realpathSync(root) } catch { return null } }).filter(Boolean)
2053
+ fsWorkspaceRootsCache = { roots, reals, fetchedAt: Date.now() }
2054
+ } catch {
2055
+ // DSH 重启期间保留上次成功的工作区根;缓存为空时仍仅允许显式 FS_ROOTS。
2056
+ fsWorkspaceRootsCache = { ...fsWorkspaceRootsCache, fetchedAt: Date.now() }
2057
+ } finally {
2058
+ fsWorkspaceRootsFetch = null
2059
+ }
2060
+ return fsWorkspaceRootsCache
2061
+ })()
2062
+ return fsWorkspaceRootsFetch
2063
+ }
2064
+
2065
+ /** 把用户给的 path 解析为绝对路径,并仅允许显式根或 DSH 已登记工作区。 */
2066
+ async function fsResolve(input) {
1395
2067
  const raw = String(input ?? '').trim()
1396
2068
  let abs
1397
2069
  if (!raw || raw === '~') abs = FS_ROOTS[0]
1398
2070
  else if (raw.startsWith('~/')) abs = path.resolve(FS_DEFAULT_ROOT, raw.slice(2))
1399
2071
  else if (path.isAbsolute(raw)) abs = path.resolve(raw)
1400
2072
  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
- }
2073
+ if (FS_ROOTS.some(root => fsInsideRoot(abs, root))) return { abs }
2074
+ let workspaces = await loadFsWorkspaceRoots(false)
2075
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
2076
+ // 新建/刚加入的工作区可能还没进入 15s 缓存,未命中时强制刷新一次。
2077
+ workspaces = await loadFsWorkspaceRoots(true)
2078
+ if (workspaces.roots.some(root => fsInsideRoot(abs, root))) return { abs }
1404
2079
  return { error: 'forbidden' }
1405
2080
  }
1406
2081
 
@@ -1443,14 +2118,14 @@ function fsParseRange(header, size) {
1443
2118
  return { start, end: Math.min(end, size - 1) }
1444
2119
  }
1445
2120
 
1446
- function fsList(req, res, url) {
2121
+ async function fsList(req, res, url) {
1447
2122
  if (req.method !== 'GET') {
1448
2123
  res.writeHead(405, { allow: 'GET' })
1449
2124
  res.end()
1450
2125
  return
1451
2126
  }
1452
2127
  if (!fsAuthorized(req, url, res)) return
1453
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2128
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1454
2129
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1455
2130
  const checked = fsRealChecked(resolved.abs)
1456
2131
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1493,14 +2168,14 @@ function fsList(req, res, url) {
1493
2168
  fsJson(res, 200, { path: resolved.abs, entries })
1494
2169
  }
1495
2170
 
1496
- function fsFile(req, res, url) {
2171
+ async function fsFile(req, res, url) {
1497
2172
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1498
2173
  res.writeHead(405, { allow: 'GET, HEAD' })
1499
2174
  res.end()
1500
2175
  return
1501
2176
  }
1502
2177
  if (!fsAuthorized(req, url, res)) return
1503
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2178
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1504
2179
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1505
2180
  const checked = fsRealChecked(resolved.abs)
1506
2181
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1541,14 +2216,14 @@ function fsFile(req, res, url) {
1541
2216
  stream.pipe(res)
1542
2217
  }
1543
2218
 
1544
- function fsPreview(req, res, url) {
2219
+ async function fsPreview(req, res, url) {
1545
2220
  if (req.method !== 'GET') {
1546
2221
  res.writeHead(405, { allow: 'GET' })
1547
2222
  res.end()
1548
2223
  return
1549
2224
  }
1550
2225
  if (!fsAuthorized(req, url, res)) return
1551
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2226
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1552
2227
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1553
2228
  const checked = fsRealChecked(resolved.abs)
1554
2229
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1797,7 +2472,7 @@ function fsTargetState(target) {
1797
2472
  }
1798
2473
  }
1799
2474
 
1800
- function fsUploadProbe(req, res, url) {
2475
+ async function fsUploadProbe(req, res, url) {
1801
2476
  if (req.method !== 'GET') {
1802
2477
  res.writeHead(405, { allow: 'GET' })
1803
2478
  res.end()
@@ -1805,7 +2480,7 @@ function fsUploadProbe(req, res, url) {
1805
2480
  }
1806
2481
  if (!fsAuthorized(req, url, res)) return
1807
2482
  touchDevice(req)
1808
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2483
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1809
2484
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1810
2485
  const checked = fsRealChecked(resolved.abs)
1811
2486
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1833,7 +2508,7 @@ function fsUploadProbe(req, res, url) {
1833
2508
  }
1834
2509
 
1835
2510
  /** POST /fs/mkdir?path=<parent>&name=<directory> 创建一个工作区目录。 */
1836
- function fsMkdir(req, res, url) {
2511
+ async function fsMkdir(req, res, url) {
1837
2512
  if (req.method !== 'POST') {
1838
2513
  res.writeHead(405, { allow: 'POST' })
1839
2514
  res.end()
@@ -1841,7 +2516,7 @@ function fsMkdir(req, res, url) {
1841
2516
  }
1842
2517
  if (!fsAuthorized(req, url, res)) return
1843
2518
  touchDevice(req)
1844
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2519
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
1845
2520
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
1846
2521
  const checked = fsRealChecked(resolved.abs)
1847
2522
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -1996,7 +2671,7 @@ function fsUploadResumable(req, res, url, dirLex, dirReal) {
1996
2671
 
1997
2672
  /* POST /fs/upload-control?path&name&session&action=cancel
1998
2673
  * 取消续传: 停止在途写流并删除分片(暂停由客户端 abort 完成, 分片保留)。 */
1999
- function fsUploadControl(req, res, url) {
2674
+ async function fsUploadControl(req, res, url) {
2000
2675
  if (req.method !== 'POST') {
2001
2676
  res.writeHead(405, { allow: 'POST' })
2002
2677
  res.end()
@@ -2004,7 +2679,7 @@ function fsUploadControl(req, res, url) {
2004
2679
  }
2005
2680
  if (!fsAuthorized(req, url, res)) return
2006
2681
  touchDevice(req)
2007
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2682
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
2008
2683
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2009
2684
  const checked = fsRealChecked(resolved.abs)
2010
2685
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -2030,7 +2705,7 @@ function fsUploadControl(req, res, url) {
2030
2705
  }, 80)
2031
2706
  }
2032
2707
 
2033
- function serveFs(req, res, url) {
2708
+ async function serveFs(req, res, url) {
2034
2709
  const sub = url.pathname.slice('/fs'.length)
2035
2710
 
2036
2711
  // 跨域预检: 浏览器控制台可能从 DSH /remote 页访问网关(Authorization 非简单头)
@@ -2056,7 +2731,7 @@ function serveFs(req, res, url) {
2056
2731
  }
2057
2732
  if (!fsAuthorized(req, url, res)) return
2058
2733
  touchDevice(req)
2059
- const resolved = fsResolve(url.searchParams.get('path') ?? '')
2734
+ const resolved = await fsResolve(url.searchParams.get('path') ?? '')
2060
2735
  if (resolved.error) return fsJson(res, resolved.error === 'forbidden' ? 403 : 404, { error: resolved.error })
2061
2736
  const checked = fsRealChecked(resolved.abs)
2062
2737
  if (checked.error) return fsJson(res, checked.error === 'forbidden' ? 403 : 404, { error: checked.error })
@@ -2293,6 +2968,8 @@ async function serveHealth(res) {
2293
2968
  ok: true,
2294
2969
  service: 'dsh-remote',
2295
2970
  version: VERSION,
2971
+ protocol: { version: PROTOCOL_VERSION },
2972
+ capabilities: CAPABILITIES,
2296
2973
  pid: process.pid,
2297
2974
  upstream: UPSTREAM.origin,
2298
2975
  upstreamProbe: DSH_HEALTH_PATH,
@@ -2317,10 +2994,10 @@ function lanAddresses() {
2317
2994
  return out
2318
2995
  }
2319
2996
 
2320
- const server = http.createServer((req, res) => {
2997
+ const server = http.createServer(async (req, res) => {
2321
2998
  try {
2322
2999
  const url = new URL(req.url, 'http://dsh-remote.local')
2323
- if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
3000
+ if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return await serveFs(req, res, url)
2324
3001
  if (url.pathname === '/workbench' || url.pathname.startsWith('/workbench/')) return serveWorkbench(req, res, url)
2325
3002
  if (url.pathname === '/feedback') return serveFeedback(req, res, url)
2326
3003
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
@@ -2640,10 +3317,11 @@ server.on('clientError', (err, socket) => {
2640
3317
  })
2641
3318
 
2642
3319
  server.listen(PORT, HOST, () => {
3320
+ const clientToken = deviceKeyState.enabled ? deviceKeyState.keys[0]?.token : TOKEN
2643
3321
  console.log('DSH Remote 网关 v' + VERSION + ' 已启动')
2644
- console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + TOKEN)
3322
+ console.log(' 本机: http://127.0.0.1:' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
2645
3323
  for (const ip of lanAddresses()) {
2646
- console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + TOKEN)
3324
+ console.log(' 手机(同一网络): http://' + ip + ':' + PORT + '/?token=' + (clientToken || '请在管理页创建设备密钥'))
2647
3325
  }
2648
3326
  console.log(' 管理页: http://127.0.0.1:' + PORT + '/admin')
2649
3327
  if (HOST === '127.0.0.1') {
@@ -2651,8 +3329,8 @@ server.listen(PORT, HOST, () => {
2651
3329
  }
2652
3330
  console.log(' 上游: ' + UPSTREAM.origin + ' (Ctrl+C 退出)')
2653
3331
  // 事件轮询缓冲:网关自身上游 WS 采集,断线自动重连
2654
- startEventCollector('mux')
2655
- startEventCollector('host')
3332
+ eventCollectors.mux = startEventCollector('mux')
3333
+ eventCollectors.host = startEventCollector('host')
2656
3334
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
2657
3335
  setTimeout(() => checkForUpdates(false), 8000)
2658
3336
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)