dsh-dock 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// 统一经本网关访问:未登录一律跳转登录页(账号 + 密码),登录成功发放 HttpOnly
|
|
5
5
|
// 会话 Cookie(7 天滑动过期),支持退出登录;登录失败按来源 IP 限速。上游即 DSH
|
|
6
6
|
// 主实例:绑定回环时把 Host/Origin 改写为回环(回环恒被信任),已绑 0.0.0.0 时
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// 透传以走局域网信任派生。宿主可注入 mintUpstreamCookie:服务端兑换 dsh web 的
|
|
8
|
+
// 启动令牌会话并在代理时自动附带(含 WebSocket 升级),远程浏览器只需账号密码。
|
|
9
|
+
// HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板显示退出按钮)与
|
|
10
|
+
// crypto.randomUUID 兜底脚本引用。
|
|
9
11
|
import { createServer, request as httpRequest } from 'node:http'
|
|
10
12
|
import { networkInterfaces } from 'node:os'
|
|
11
13
|
import { randomBytes } from 'node:crypto'
|
|
@@ -153,7 +155,7 @@ function writeUpgradeHead(socket, response) {
|
|
|
153
155
|
* (读 settings 中的加盐哈希做常量时间比对);会话 Cookie 7 天滑动过期;
|
|
154
156
|
* revokeAllSessions() 在账号密码变更时作废所有已登录设备。
|
|
155
157
|
*/
|
|
156
|
-
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort, spoofLoopback = false, verifyLogin, sessionTtlMs = SESSION_TTL_MS }) {
|
|
158
|
+
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort, spoofLoopback = false, verifyLogin, mintUpstreamCookie, sessionTtlMs = SESSION_TTL_MS }) {
|
|
157
159
|
return new Promise((resolve, reject) => {
|
|
158
160
|
const sessions = new Map() // cookie secret -> { expiresAt, seenAt }
|
|
159
161
|
const upgradedSockets = new Map() // client socket -> { cookie, upstreamSocket }
|
|
@@ -161,6 +163,46 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
161
163
|
const patchedBundles = new Map() // 上游 client.js URL(含 rev)-> 改写后字节
|
|
162
164
|
const loopbackAuthority = '127.0.0.1:' + upstreamPort
|
|
163
165
|
|
|
166
|
+
// 上游会话(dsh web 自己的启动令牌认证):mintUpstreamCookie 由宿主注入,
|
|
167
|
+
// 服务端完成 /?token= 兑换并持有上游会话 Cookie,代理请求时自动附带——远程
|
|
168
|
+
// 浏览器只需网关账号登录,无需再手动访问令牌地址。仅在改写回环(上游仅绑
|
|
169
|
+
// 回环、Cookie 与该 authority 绑定)时附带;兑换失败退回官方行为。
|
|
170
|
+
let upstreamCookie = null
|
|
171
|
+
let minting = null
|
|
172
|
+
let lastMintAt = 0
|
|
173
|
+
async function ensureUpstreamCookie() {
|
|
174
|
+
if (!spoofLoopback || typeof mintUpstreamCookie !== 'function') return null
|
|
175
|
+
if (upstreamCookie) return upstreamCookie
|
|
176
|
+
if (minting) return minting
|
|
177
|
+
if (now() - lastMintAt < 5000) return null
|
|
178
|
+
lastMintAt = now()
|
|
179
|
+
minting = Promise.resolve().then(mintUpstreamCookie).then((cookie) => {
|
|
180
|
+
minting = null
|
|
181
|
+
upstreamCookie = typeof cookie === 'string' && cookie.includes('=') ? cookie : null
|
|
182
|
+
return upstreamCookie
|
|
183
|
+
}).catch(() => { minting = null; return null })
|
|
184
|
+
return minting
|
|
185
|
+
}
|
|
186
|
+
function dropUpstreamCookie() {
|
|
187
|
+
upstreamCookie = null
|
|
188
|
+
lastMintAt = 0
|
|
189
|
+
}
|
|
190
|
+
// 同名 Cookie 浏览器侧可能持有旧值(换 secret 后失效),附加以我们的为准
|
|
191
|
+
async function upstreamRequestHeaders(req) {
|
|
192
|
+
const headers = proxyHeaders(req)
|
|
193
|
+
const cookie = await ensureUpstreamCookie()
|
|
194
|
+
if (cookie) {
|
|
195
|
+
const name = cookie.slice(0, cookie.indexOf('=')).trim()
|
|
196
|
+
const own = String(headers.cookie || '')
|
|
197
|
+
.split(';')
|
|
198
|
+
.map((part) => part.trim())
|
|
199
|
+
.filter((part) => part && part.slice(0, part.indexOf('=')).trim() !== name)
|
|
200
|
+
.join('; ')
|
|
201
|
+
headers.cookie = own ? own + '; ' + cookie : cookie
|
|
202
|
+
}
|
|
203
|
+
return headers
|
|
204
|
+
}
|
|
205
|
+
|
|
164
206
|
function purge() {
|
|
165
207
|
const stamp = now()
|
|
166
208
|
const expired = []
|
|
@@ -220,72 +262,80 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
220
262
|
if (wantsHtml) delete next['accept-encoding']
|
|
221
263
|
return next
|
|
222
264
|
}
|
|
223
|
-
function proxyHttp(req, res) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const limit = isConnectionBundle ? MAX_ASSET_BYTES : MAX_HTML_BYTES
|
|
252
|
-
upstreamRes.on('data', (chunk) => {
|
|
253
|
-
size += chunk.length
|
|
254
|
-
if (size <= limit) chunks.push(chunk)
|
|
255
|
-
})
|
|
256
|
-
upstreamRes.on('end', () => {
|
|
257
|
-
if (size > limit) {
|
|
258
|
-
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
|
|
265
|
+
async function proxyHttp(req, res) {
|
|
266
|
+
let upstream
|
|
267
|
+
try {
|
|
268
|
+
upstream = httpRequest({
|
|
269
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
270
|
+
headers: await upstreamRequestHeaders(req),
|
|
271
|
+
}, (upstreamRes) => {
|
|
272
|
+
// 上游 401 说明持有的会话已失效(secret 轮换/过期),丢弃后下次请求重兑
|
|
273
|
+
if (upstreamRes.statusCode === 401) dropUpstreamCookie()
|
|
274
|
+
// 浏览器侧中途断开时把上游响应读完丢弃(而非 destroy)——宿主对半路 RST
|
|
275
|
+
// 敏感,读完让上游 socket 干净收尾复用。缓冲分支天然读尽,无需处理。
|
|
276
|
+
if (upstreamRes) upstreamRes.on('error', () => res.destroy())
|
|
277
|
+
const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
|
|
278
|
+
// 连接客户端 bundle:设置面在远程浏览器被客户端自我判定关死(见文件头注释),
|
|
279
|
+
// 做定点改写;命中缓存直接回,改不动(上游升级)则原样透传。
|
|
280
|
+
const isConnectionBundle = req.method === 'GET' && String(req.url || '').startsWith(CONNECTION_BUNDLE_PREFIX)
|
|
281
|
+
&& contentType.includes('javascript')
|
|
282
|
+
if (isConnectionBundle && patchedBundles.has(req.url)) {
|
|
283
|
+
const headers = { ...upstreamRes.headers, 'content-length': String(patchedBundles.get(req.url).length) }
|
|
284
|
+
delete headers['transfer-encoding']
|
|
285
|
+
res.writeHead(upstreamRes.statusCode || 200, headers)
|
|
286
|
+
return res.end(patchedBundles.get(req.url))
|
|
287
|
+
}
|
|
288
|
+
if (!contentType.includes('text/html') && !isConnectionBundle) {
|
|
289
|
+
// 直通分支:浏览器断开就停止写、改为耗尽上游响应(读完丢弃)。
|
|
290
|
+
res.on('close', () => { if (!upstreamRes.complete) upstreamRes.resume() })
|
|
291
|
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
|
|
292
|
+
upstreamRes.pipe(res)
|
|
259
293
|
return
|
|
260
294
|
}
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
295
|
+
const chunks = []
|
|
296
|
+
let size = 0
|
|
297
|
+
const limit = isConnectionBundle ? MAX_ASSET_BYTES : MAX_HTML_BYTES
|
|
298
|
+
upstreamRes.on('data', (chunk) => {
|
|
299
|
+
size += chunk.length
|
|
300
|
+
if (size <= limit) chunks.push(chunk)
|
|
301
|
+
})
|
|
302
|
+
upstreamRes.on('end', () => {
|
|
303
|
+
if (size > limit) {
|
|
304
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const headers = { ...upstreamRes.headers }
|
|
308
|
+
delete headers['content-length']
|
|
309
|
+
delete headers['content-encoding']
|
|
310
|
+
delete headers['transfer-encoding']
|
|
311
|
+
headers['cache-control'] = 'no-store'
|
|
312
|
+
let body = Buffer.concat(chunks)
|
|
313
|
+
if (isConnectionBundle) {
|
|
314
|
+
const text = body.toString('utf8')
|
|
315
|
+
const at = text.indexOf(CONNECTION_ISLOOPBACK_SNIPPET)
|
|
316
|
+
if (at >= 0) {
|
|
317
|
+
body = Buffer.from(text.slice(0, at) + CONNECTION_ISLOOPBACK_PATCHED + text.slice(at + CONNECTION_ISLOOPBACK_SNIPPET.length), 'utf8')
|
|
318
|
+
if (patchedBundles.size >= PATCH_CACHE_LIMIT) patchedBundles.clear()
|
|
319
|
+
patchedBundles.set(req.url, body)
|
|
320
|
+
}
|
|
321
|
+
headers['content-length'] = String(body.length)
|
|
322
|
+
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
323
|
+
return res.end(body)
|
|
274
324
|
}
|
|
275
|
-
headers['content-length'] = String(body.length)
|
|
276
325
|
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
280
|
-
res.end(injectMobileCompat(body.toString('utf8')))
|
|
326
|
+
res.end(injectMobileCompat(body.toString('utf8')))
|
|
327
|
+
})
|
|
281
328
|
})
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
329
|
+
upstream.on('error', (error) => {
|
|
330
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
|
|
331
|
+
else res.destroy(error)
|
|
332
|
+
})
|
|
333
|
+
req.on('error', () => upstream.destroy())
|
|
334
|
+
req.pipe(upstream)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + (error && error.message || String(error)) })
|
|
285
337
|
else res.destroy(error)
|
|
286
|
-
}
|
|
287
|
-
req.on('error', () => upstream.destroy())
|
|
288
|
-
req.pipe(upstream)
|
|
338
|
+
}
|
|
289
339
|
}
|
|
290
340
|
|
|
291
341
|
const server = createServer(async (req, res) => {
|
|
@@ -357,17 +407,23 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
357
407
|
return proxyHttp(req, res)
|
|
358
408
|
})
|
|
359
409
|
|
|
360
|
-
server.on('upgrade', (req, socket, head) => {
|
|
410
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
361
411
|
const authorized = authorize(req)
|
|
362
412
|
if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
|
|
363
413
|
const connection = { cookie: authorized.cookie, upstreamSocket: null }
|
|
364
414
|
upgradedSockets.set(socket, connection)
|
|
365
415
|
const forgetSocket = () => upgradedSockets.delete(socket)
|
|
366
416
|
socket.once('close', forgetSocket)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
417
|
+
let upstream
|
|
418
|
+
try {
|
|
419
|
+
upstream = httpRequest({
|
|
420
|
+
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
421
|
+
headers: await upstreamRequestHeaders(req),
|
|
422
|
+
})
|
|
423
|
+
} catch {
|
|
424
|
+
socket.destroy()
|
|
425
|
+
return
|
|
426
|
+
}
|
|
371
427
|
upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
|
|
372
428
|
connection.upstreamSocket = upstreamSocket
|
|
373
429
|
writeUpgradeHead(socket, response)
|
|
@@ -378,6 +434,7 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
378
434
|
socket.pipe(upstreamSocket).pipe(socket)
|
|
379
435
|
})
|
|
380
436
|
upstream.on('response', (response) => {
|
|
437
|
+
if (response.statusCode === 401) dropUpstreamCookie()
|
|
381
438
|
socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
|
|
382
439
|
})
|
|
383
440
|
upstream.on('error', () => socket.destroy())
|
|
@@ -264,7 +264,9 @@ export const feature = {
|
|
|
264
264
|
id: 'mobile-relay',
|
|
265
265
|
name: '远程访问',
|
|
266
266
|
description: '账号密码登录的远程入口:局域网/虚拟网设备访问同一个 DSH,任务进度实时一致',
|
|
267
|
-
|
|
267
|
+
// 宿主半部必须随插件加载(网关路由/账号服务都在这里),techfunway-dsh 等部署
|
|
268
|
+
// 完全依赖它——保持默认开启;面板开关只影响浏览器侧 UI(双向同步未打通)
|
|
269
|
+
defaultEnabled: true,
|
|
268
270
|
setup(ctx) {
|
|
269
271
|
const disposers = []
|
|
270
272
|
let gateway = null
|
|
@@ -372,6 +374,26 @@ export const feature = {
|
|
|
372
374
|
return sameText(username, auth.username) && sameText(hashPassword(auth.salt, password), auth.passwordHash)
|
|
373
375
|
}
|
|
374
376
|
|
|
377
|
+
// 上游会话自动兑换:dsh web 自身还有一层启动令牌认证(首次访问 /?token= 换
|
|
378
|
+
// 30 天会话 Cookie,绑定回环 authority)。网关与主实例同进程,直接取
|
|
379
|
+
// connection.authenticatedUrl 生成的带令牌地址,服务端完成兑换并返回 Cookie
|
|
380
|
+
// 对,由网关在代理时自动附带——远程浏览器只需网关账号登录,无需再手动访问
|
|
381
|
+
// 令牌地址。老版本 dsh 没有 connection 服务时返回空,网关退回官方行为。
|
|
382
|
+
async function mintUpstreamSession(mainPort) {
|
|
383
|
+
try {
|
|
384
|
+
const connection = ctx.get('connection')
|
|
385
|
+
if (!connection || typeof connection.authenticatedUrl !== 'function') return null
|
|
386
|
+
const url = connection.authenticatedUrl(`http://127.0.0.1:${mainPort}`)
|
|
387
|
+
const res = await fetch(url, { redirect: 'manual' })
|
|
388
|
+
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [res.headers.get('set-cookie')].filter(Boolean)
|
|
389
|
+
for (const raw of cookies) {
|
|
390
|
+
const pair = String(raw).split(';')[0].trim()
|
|
391
|
+
if (pair.includes('=')) return pair
|
|
392
|
+
}
|
|
393
|
+
return null
|
|
394
|
+
} catch { return null }
|
|
395
|
+
}
|
|
396
|
+
|
|
375
397
|
/** 远程访问状态:网关、账号、浏览模式补丁、旧服务器模式残留。 */
|
|
376
398
|
function lanStatus(webServer) {
|
|
377
399
|
const file = serverPatchFile()
|
|
@@ -404,7 +426,7 @@ export const feature = {
|
|
|
404
426
|
}
|
|
405
427
|
|
|
406
428
|
/** 远程访问网关:上游即主实例;主实例保持仅本机,远程一律经账号登录进入。 */
|
|
407
|
-
async function ensureGateway(webServer, requestedPort) {
|
|
429
|
+
async function ensureGateway(webServer, requestedPort, mintUpstreamCookie) {
|
|
408
430
|
// 默认端口跟主实例走(主端口+1):网关和主服务是同一台机器上的两个监听,
|
|
409
431
|
// 结构上不能同端口;跟随主端口让"哪个口是哪个"一目了然。
|
|
410
432
|
const port = Number(requestedPort === undefined || requestedPort === null || requestedPort === ''
|
|
@@ -435,6 +457,7 @@ export const feature = {
|
|
|
435
457
|
upstreamPort: webServer.port,
|
|
436
458
|
spoofLoopback: true,
|
|
437
459
|
verifyLogin,
|
|
460
|
+
mintUpstreamCookie: typeof mintUpstreamCookie === 'function' ? mintUpstreamCookie : undefined,
|
|
438
461
|
})
|
|
439
462
|
.then((started) => { gateway = started; gatewayStarting = null; return started })
|
|
440
463
|
.catch((error) => { gatewayStarting = null; throw error })
|
|
@@ -511,7 +534,7 @@ export const feature = {
|
|
|
511
534
|
const list = readPatchList(file) || []
|
|
512
535
|
const changed = !serverPatchApplied(list) || legacyWebserverRowPresent(list)
|
|
513
536
|
writePatchList(file, upsertRemotePatches(list))
|
|
514
|
-
await ensureGateway(wsCtx.webServer, payload && payload.port)
|
|
537
|
+
await ensureGateway(wsCtx.webServer, payload && payload.port, () => mintUpstreamSession(wsCtx.webServer.port))
|
|
515
538
|
return sendJson(res, 200, { ok: true, data: { ...lanStatus(wsCtx.webServer), needsRestart: changed } })
|
|
516
539
|
}
|
|
517
540
|
if (method === 'lan/stop') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dock",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景、完成/需确认通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|