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/README.en.md +3 -2
- package/README.md +3 -2
- package/apk/dsh-remote.apk +0 -0
- package/gateway.cjs +760 -82
- package/index.mjs +32 -6
- package/package.json +1 -1
- package/public/admin.html +157 -8
- package/public/admin.js +264 -5
- package/public/announcements.json +35 -0
- package/public/app.js +475 -55
- package/public/desktop/desktop.css +5 -0
- package/public/desktop/desktop.html +4 -2
- package/public/desktop/desktop.js +103 -14
- package/public/index.html +79 -16
- package/public/styles.css +64 -2
- package/public/update.json +13 -13
- package/public/version.json +1 -1
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
1049
|
+
if (dshControlOperation && !dshControlOperation.done) {
|
|
616
1050
|
cors(res)
|
|
617
|
-
res.writeHead(
|
|
618
|
-
res.end(JSON.stringify({ ok: false,
|
|
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
|
|
622
|
-
|
|
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(
|
|
625
|
-
res.end(JSON.stringify(
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
if (
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
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=' +
|
|
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=' +
|
|
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)
|