dsh-dock 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE +18 -18
  2. package/README.md +325 -325
  3. package/client.js +42 -8
  4. package/cordis.patch.yml +2 -2
  5. package/features/animation/host.js +660 -660
  6. package/features/animation/view.jsx +2247 -2247
  7. package/features/balance/host.js +494 -494
  8. package/features/balance/view.js +351 -351
  9. package/features/games/games/breakout.jsx +227 -227
  10. package/features/games/games/gomoku.jsx +244 -244
  11. package/features/games/games/racer.jsx +169 -169
  12. package/features/games/games/shared.jsx +118 -118
  13. package/features/games/games/snake.jsx +144 -144
  14. package/features/games/games/sokoban.jsx +180 -180
  15. package/features/games/games/tank.jsx +128 -128
  16. package/features/games/games/tetris.jsx +308 -308
  17. package/features/games/games/xiangqi.jsx +277 -277
  18. package/features/games/tank-core.js +152 -152
  19. package/features/games/view.jsx +639 -639
  20. package/features/games/xiangqi-core.js +299 -299
  21. package/features/heartbeat/view.js +35 -35
  22. package/features/mobile-relay/gateway.js +472 -415
  23. package/features/mobile-relay/host.js +570 -546
  24. package/features/mobile-relay/view.jsx +300 -300
  25. package/features/modelconfig/host.js +403 -403
  26. package/features/modelconfig/view.js +490 -490
  27. package/features/theme/view.js +61 -61
  28. package/features/tokenlog/host.js +678 -678
  29. package/features/tokenlog/view.jsx +561 -561
  30. package/features/visionproxy/host.js +315 -315
  31. package/index.js +175 -118
  32. package/package.json +59 -59
  33. package/src/client.jsx +668 -668
  34. package/src/host-core.js +98 -95
  35. package/src/shared.js +167 -130
@@ -1,415 +1,472 @@
1
- // dsh-dock · 远程访问安全网关
2
- //
3
- // 主 DSH 实例只监听 127.0.0.1(结构上不存在无登录的远程路径);局域网/虚拟网设备
4
- // 统一经本网关访问:未登录一律跳转登录页(账号 + 密码),登录成功发放 HttpOnly
5
- // 会话 Cookie(7 天滑动过期),支持退出登录;登录失败按来源 IP 限速。上游即 DSH
6
- // 主实例:绑定回环时把 Host/Origin 改写为回环(回环恒被信任),已绑 0.0.0.0 时
7
- // 透传以走局域网信任派生。HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板
8
- // 显示退出按钮)与 crypto.randomUUID 兜底脚本引用。
9
- import { createServer, request as httpRequest } from 'node:http'
10
- import { networkInterfaces } from 'node:os'
11
- import { randomBytes } from 'node:crypto'
12
-
13
- const COOKIE_NAME = 'dsh_remote_session'
14
- const LOGIN_PATH = '/__dsh_auth/login'
15
- const LOGOUT_PATH = '/__dsh_auth/logout'
16
- const HEALTH_PATH = '/__dsh_auth/health'
17
- const COMPAT_PATH = '/__dsh_mobile/compat.js'
18
- const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
19
- const MAX_LOGIN_BODY = 1024
20
- const MAX_HTML_BYTES = 2 * 1024 * 1024
21
- const LOGIN_WINDOW_MS = 10 * 60 * 1000
22
- const MAX_LOGIN_ATTEMPTS = 10
23
-
24
- // 远程设置面补丁:DSH 客户端按"页面主机名是否回环"决定设置镜像模式(非回环
25
- // 构造 memory 镜像、永不读档,设置/模型页全部显示 unavailable)。而服务端信任栏
26
- // 只看 Host/Origin——经本网关(登录后)的请求早已映射为回环信任,特权接口实际
27
- // 全部放行。唯一拦住手机用户的只剩客户端这一处自我判定,所以对本 bundle 做定点
28
- // 改写(判定恒真)。上游升级改了片段则原样透传(退化为官方行为,不影响其他功能)。
29
- const CONNECTION_BUNDLE_PREFIX = '/plugins/@deepseek-ai/dsh-client-connection/client.js'
30
- const CONNECTION_ISLOOPBACK_SNIPPET = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)'
31
- const CONNECTION_ISLOOPBACK_PATCHED = 'isLoopback: !0 /* dsh-dock remote gateway */'
32
- const MAX_ASSET_BYTES = 4 * 1024 * 1024
33
- const PATCH_CACHE_LIMIT = 16
34
-
35
- // LAN HTTP is not a browser secure context. Some browsers therefore expose
36
- // crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
37
- // connection/workspace clients call randomUUID() directly. ES5, precondition-free
38
- // fallback: defines randomUUID (and a getRandomValues last resort) as both an own
39
- // property and on Crypto.prototype. Injected before every DSH bootstrap script.
40
- const MOBILE_COMPAT_JS = `(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()`
41
-
42
- function secret() { return randomBytes(32).toString('base64url') }
43
- function now() { return Date.now() }
44
- function isPrivateIpv4(ip) {
45
- const p = String(ip).split('.').map(Number)
46
- return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
47
- }
48
- function interfacePriority(name) {
49
- if (/^en0$/.test(name)) return 100
50
- if (/^en\d+$/.test(name)) return 90
51
- if (/^(eth|enp|wlan|wl)/.test(name)) return 80
52
- if (/^bridge/.test(name)) return 20
53
- if (/^(utun|tun|tap)/.test(name)) return 10
54
- return 50
55
- }
56
-
57
- /** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
58
- export function lanAddresses() {
59
- const rows = []
60
- const table = networkInterfaces()
61
- for (const [name, list] of Object.entries(table)) {
62
- for (const item of list || []) {
63
- if (!item || item.internal || item.family !== 'IPv4') continue
64
- rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
65
- }
66
- }
67
- return rows.sort((a, b) => Number(b.private) - Number(a.private)
68
- || interfacePriority(b.interface) - interfacePriority(a.interface)
69
- || a.interface.localeCompare(b.interface))
70
- }
71
-
72
- function parseCookies(header) {
73
- const result = {}
74
- for (const part of String(header || '').split(';')) {
75
- const at = part.indexOf('=')
76
- if (at <= 0) continue
77
- result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
78
- }
79
- return result
80
- }
81
- function sendJson(res, status, value, headers = {}) {
82
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
83
- res.end(JSON.stringify(value))
84
- }
85
- function readSmallBody(req) {
86
- return new Promise((resolve, reject) => {
87
- let size = 0
88
- const chunks = []
89
- req.on('data', (chunk) => {
90
- size += chunk.length
91
- if (size > MAX_LOGIN_BODY) { reject(new Error('请求过大')); req.destroy(); return }
92
- chunks.push(chunk)
93
- })
94
- req.on('end', () => resolve(Buffer.concat(chunks)))
95
- req.on('error', reject)
96
- })
97
- }
98
- // 登录页表单是原生 POST(application/x-www-form-urlencoded);面板/脚本走 JSON。
99
- // 表单流的成功/失败都用 303 重定向表达(失败回到 ?e=1 错误态登录页),JSON 流保持
100
- // JSON 响应(200/401/400),两种客户端互不干扰。
101
- const PAGE_STYLE = `*{box-sizing:border-box}html{font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;color-scheme:dark light;height:100%}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:radial-gradient(1200px 600px at 20% -10%,#1d4ed81f,transparent),radial-gradient(900px 500px at 110% 110%,#0ea5e921,transparent),#0f1115;color:#eef2f8}@media(prefers-color-scheme:light){body{background:radial-gradient(1200px 600px at 20% -10%,#3b82f614,transparent),radial-gradient(900px 500px at 110% 110%,#0ea5e918,transparent),#eef1f6;color:#16181d}}.card{width:min(400px,calc(100vw - 32px));padding:32px 28px;border:1px solid #ffffff14;border-radius:18px;background:#161a22e6;box-shadow:0 24px 80px #00000059;backdrop-filter:blur(10px)}@media(prefers-color-scheme:light){.card{background:#ffffffd9;border-color:#00000014;box-shadow:0 24px 70px #94a3b840}}.brand{display:flex;align-items:center;gap:12px;margin-bottom:22px}.brand .icon{width:44px;height:44px;flex:none;display:grid;place-items:center;border-radius:13px;background:linear-gradient(135deg,#38bdf8,#6366f1);color:#fff;box-shadow:0 8px 24px #38bdf840}.brand .icon svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.brand h1{margin:0;font-size:18px;letter-spacing:.01em}.brand small{display:block;margin-top:2px;color:#8b93a3;font-size:12px;font-weight:400}label{display:block;margin-bottom:14px;font-size:13px;font-weight:600;color:#aab2c0}@media(prefers-color-scheme:light){label{color:#586173}}input{width:100%;margin-top:6px;padding:11px 13px;border:1px solid #ffffff1f;border-radius:11px;background:#0d1016;color:inherit;font:inherit;font-size:15px;outline:none;transition:border-color .15s,box-shadow .15s}@media(prefers-color-scheme:light){input{background:#f4f6fa;border-color:#0000001a}}input:focus{border-color:#38bdf8;box-shadow:0 0 0 3px #38bdf82e}button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;background:linear-gradient(135deg,#38bdf8,#6366f1);color:#fff;font:inherit;font-size:15px;font-weight:700;cursor:pointer;transition:filter .15s,transform .1s}button:hover{filter:brightness(1.07)}button:active{transform:scale(.99)}.error{margin:-4px 0 12px;padding:9px 12px;border-radius:10px;background:#ef44441f;color:#ff9d9d;font-size:13px;line-height:1.5}@media(prefers-color-scheme:light){.error{color:#c23434}}.noerror{display:none}.hint{margin:16px 0 0;color:#8b93a3;font-size:12px;line-height:1.6;text-align:center}.done{text-align:center}.done .ok{width:52px;height:52px;margin:4px auto 14px;display:grid;place-items:center;border-radius:50%;background:#22c55e26;color:#4ade80}.done .ok svg{width:26px;height:26px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}.done p{margin:0 0 18px;color:#8b93a3;font-size:13px}`
102
- const PAGE_ICON = `<span class="icon"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></span>`
103
-
104
- function loginPageHtml(withError) {
105
- const errorRow = withError
106
- ? `<div class="error" role="alert">账号或密码不正确,请重试。</div>`
107
- : `<div class="noerror"></div>`
108
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>DSH 远程访问</title><style>${PAGE_STYLE}</style></head><body><main class="card"><form method="POST" action="/__dsh_auth/login"><div class="brand">${PAGE_ICON}<div><h1>DSH 远程访问</h1><small>登录后可使用完整功能</small></div></div>${errorRow}<label>账号<input name="username" autocomplete="username" autofocus required></label><label>密码<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">登 录</button><p class="hint">仅限授权设备登录 · 会话 7 天内免重复登录</p></form></main></body></html>`
109
- }
110
-
111
- function logoutPageHtml() {
112
- return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"><title>已退出 · DSH 远程访问</title><meta http-equiv="refresh" content="2;url=/__dsh_auth/login"><style>${PAGE_STYLE}</style></head><body><main class="card done"><div class="ok"><svg viewBox="0 0 24 24"><path d="m5 12 4.2 4.2L19 6.5"/></svg></div><p>已退出登录。正在返回登录页…</p><button type="button" onclick="location.replace('/__dsh_auth/login')">立即返回</button></main></body></html>`
113
- }
114
- function passthroughProxyHeaders(headers) {
115
- // 上游绑定 0.0.0.0(服务器模式):绑定即派生局域网 IP 信任(trustedHosts),
116
- // 信任栏阻(api-request-trust)校验的是请求自带的 Host/Origin 一致性——原样透传
117
- // Host、Origin 与 Cookie 即可通过;仅在 HTML 注入兼容层需要时去掉 accept-encoding。
118
- return { ...headers }
119
- }
120
-
121
- function spoofLoopbackHeaders(headers, loopbackAuthority) {
122
- // 上游仅绑回环(未开服务器模式):局域网地址不在信任列表,必须把 Host/Origin
123
- // 一并改写成回环(回环恒被信任)。Cookie 仍原样透传(上游无 Cookie 需求)。
124
- const next = { ...headers, host: loopbackAuthority }
125
- if (next.origin) next.origin = 'http://' + loopbackAuthority
126
- // HTML must stay uncompressed so the proxy can inject the early compatibility script.
127
- delete next['accept-encoding']
128
- return next
129
- }
130
-
131
- function htmlInjectionHeaders(headers) {
132
- // HTML must stay uncompressed so the proxy can inject the early compatibility script.
133
- const next = { ...headers }
134
- delete next['accept-encoding']
135
- return next
136
- }
137
- function injectMobileCompat(html) {
138
- if (html.includes('data-dsh-mobile-compat')) return html
139
- const match = /<head(?:\s[^>]*)?>/i.exec(html)
140
- const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
141
- if (!match) return script + html
142
- const at = match.index + match[0].length
143
- return html.slice(0, at) + script + html.slice(at)
144
- }
145
- function writeUpgradeHead(socket, response) {
146
- const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
147
- for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
148
- socket.write(lines.join('\r\n') + '\r\n\r\n')
149
- }
150
-
151
- /**
152
- * 开启需要账号密码登录的远程访问网关。verifyLogin(username, password) 由宿主注入
153
- * (读 settings 中的加盐哈希做常量时间比对);会话 Cookie 7 天滑动过期;
154
- * revokeAllSessions() 在账号密码变更时作废所有已登录设备。
155
- */
156
- export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort, spoofLoopback = false, verifyLogin, sessionTtlMs = SESSION_TTL_MS }) {
157
- return new Promise((resolve, reject) => {
158
- const sessions = new Map() // cookie secret -> { expiresAt, seenAt }
159
- const upgradedSockets = new Map() // client socket -> { cookie, upstreamSocket }
160
- const loginAttempts = new Map() // ip -> { count, firstAt }
161
- const patchedBundles = new Map() // 上游 client.js URL(含 rev)-> 改写后字节
162
- const loopbackAuthority = '127.0.0.1:' + upstreamPort
163
-
164
- function purge() {
165
- const stamp = now()
166
- const expired = []
167
- for (const [key, value] of sessions) if (value.expiresAt <= stamp) { sessions.delete(key); expired.push(key) }
168
- for (const [key, item] of loginAttempts) if (stamp - item.firstAt > LOGIN_WINDOW_MS) loginAttempts.delete(key)
169
- if (expired.length) {
170
- for (const [socket, value] of upgradedSockets) {
171
- if (expired.includes(value.cookie)) {
172
- if (value.upstreamSocket) value.upstreamSocket.destroy()
173
- socket.destroy()
174
- }
175
- }
176
- }
177
- }
178
- const purgeTimer = setInterval(purge, 60 * 1000)
179
-
180
- function sessionCookieOf(req) {
181
- return parseCookies(req.headers.cookie)[COOKIE_NAME] || null
182
- }
183
- function authorize(req) {
184
- purge()
185
- const cookie = sessionCookieOf(req)
186
- const session = cookie ? sessions.get(cookie) : null
187
- if (!session) return null
188
- session.expiresAt = now() + sessionTtlMs
189
- session.seenAt = now()
190
- return { cookie }
191
- }
192
- function loginAllowed(ip) {
193
- purge()
194
- const item = loginAttempts.get(ip)
195
- return !item || now() - item.firstAt > LOGIN_WINDOW_MS || item.count < MAX_LOGIN_ATTEMPTS
196
- }
197
- function recordLoginFail(ip) {
198
- const item = loginAttempts.get(ip) || { count: 0, firstAt: now() }
199
- if (now() - item.firstAt > LOGIN_WINDOW_MS) { item.count = 0; item.firstAt = now() }
200
- item.count++
201
- loginAttempts.set(ip, item)
202
- }
203
- function issueSession(res) {
204
- const cookie = secret()
205
- sessions.set(cookie, { expiresAt: now() + sessionTtlMs, seenAt: now() })
206
- res.setHeader('set-cookie', `${COOKIE_NAME}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1000)}`)
207
- }
208
- function dropSession(req, res) {
209
- const cookie = sessionCookieOf(req)
210
- if (cookie) sessions.delete(cookie)
211
- res.setHeader('set-cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`)
212
- }
213
- function proxyHeaders(req) {
214
- const contentTypeHint = String(req.headers['content-type'] || '')
215
- const wantsHtml = req.method === 'GET' && !contentTypeHint
216
- // 先按上游信任形态决定 Host/Origin 策略,再叠加 HTML 注入所需的去压缩。
217
- const next = spoofLoopback
218
- ? spoofLoopbackHeaders(req.headers, loopbackAuthority)
219
- : passthroughProxyHeaders(req.headers)
220
- if (wantsHtml) delete next['accept-encoding']
221
- return next
222
- }
223
- function proxyHttp(req, res) {
224
- const upstream = httpRequest({
225
- hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
226
- headers: proxyHeaders(req),
227
- }, (upstreamRes) => {
228
- // 浏览器侧中途断开时把上游响应读完丢弃(而非 destroy)——宿主对半路 RST
229
- // 敏感,读完让上游 socket 干净收尾复用。缓冲分支天然读尽,无需处理。
230
- if (upstreamRes) upstreamRes.on('error', () => res.destroy())
231
- const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
232
- // 连接客户端 bundle:设置面在远程浏览器被客户端自我判定关死(见文件头注释),
233
- // 做定点改写;命中缓存直接回,改不动(上游升级)则原样透传。
234
- const isConnectionBundle = req.method === 'GET' && String(req.url || '').startsWith(CONNECTION_BUNDLE_PREFIX)
235
- && contentType.includes('javascript')
236
- if (isConnectionBundle && patchedBundles.has(req.url)) {
237
- const headers = { ...upstreamRes.headers, 'content-length': String(patchedBundles.get(req.url).length) }
238
- delete headers['transfer-encoding']
239
- res.writeHead(upstreamRes.statusCode || 200, headers)
240
- return res.end(patchedBundles.get(req.url))
241
- }
242
- if (!contentType.includes('text/html') && !isConnectionBundle) {
243
- // 直通分支:浏览器断开就停止写、改为耗尽上游响应(读完丢弃)。
244
- res.on('close', () => { if (!upstreamRes.complete) upstreamRes.resume() })
245
- res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
246
- upstreamRes.pipe(res)
247
- return
248
- }
249
- const chunks = []
250
- let size = 0
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 页面过大,无法注入手机兼容层。' })
259
- return
260
- }
261
- const headers = { ...upstreamRes.headers }
262
- delete headers['content-length']
263
- delete headers['content-encoding']
264
- delete headers['transfer-encoding']
265
- headers['cache-control'] = 'no-store'
266
- let body = Buffer.concat(chunks)
267
- if (isConnectionBundle) {
268
- const text = body.toString('utf8')
269
- const at = text.indexOf(CONNECTION_ISLOOPBACK_SNIPPET)
270
- if (at >= 0) {
271
- body = Buffer.from(text.slice(0, at) + CONNECTION_ISLOOPBACK_PATCHED + text.slice(at + CONNECTION_ISLOOPBACK_SNIPPET.length), 'utf8')
272
- if (patchedBundles.size >= PATCH_CACHE_LIMIT) patchedBundles.clear()
273
- patchedBundles.set(req.url, body)
274
- }
275
- headers['content-length'] = String(body.length)
276
- res.writeHead(upstreamRes.statusCode || 502, headers)
277
- return res.end(body)
278
- }
279
- res.writeHead(upstreamRes.statusCode || 502, headers)
280
- res.end(injectMobileCompat(body.toString('utf8')))
281
- })
282
- })
283
- upstream.on('error', (error) => {
284
- if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
285
- else res.destroy(error)
286
- })
287
- req.on('error', () => upstream.destroy())
288
- req.pipe(upstream)
289
- }
290
-
291
- const server = createServer(async (req, res) => {
292
- const url = new URL(req.url || '/', 'http://remote.internal')
293
- const ip = (req.socket && req.socket.remoteAddress) || 'unknown'
294
-
295
- if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
296
-
297
- if (url.pathname === LOGIN_PATH && req.method === 'GET') {
298
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
299
- return res.end(loginPageHtml(url.searchParams.get('e') === '1'))
300
- }
301
- if (url.pathname === LOGIN_PATH && req.method === 'POST') {
302
- if (!loginAllowed(ip)) return sendJson(res, 429, { ok: false, error: '尝试次数过多,请 10 分钟后再试。' })
303
- const isForm = /urlencoded/i.test(String(req.headers['content-type'] || ''))
304
- let fields = null
305
- try {
306
- const raw = (await readSmallBody(req)).toString('utf8')
307
- if (isForm) {
308
- const form = new URLSearchParams(raw)
309
- fields = { username: form.get('username'), password: form.get('password') }
310
- } else {
311
- const parsed = JSON.parse(raw || '{}')
312
- fields = { username: parsed && parsed.username, password: parsed && parsed.password }
313
- }
314
- } catch { fields = null }
315
- const username = String(fields && fields.username || '').trim()
316
- const password = String(fields && fields.password || '')
317
- let ok = false
318
- try { ok = Boolean(fields && verifyLogin && await verifyLogin(username, password)) } catch { ok = false }
319
- if (!ok) {
320
- recordLoginFail(ip)
321
- if (isForm) {
322
- res.writeHead(303, { location: LOGIN_PATH + '?e=1', 'cache-control': 'no-store' })
323
- return res.end()
324
- }
325
- return sendJson(res, fields ? 401 : 400, { ok: false, error: fields ? '账号或密码不正确。' : '请求格式错误' })
326
- }
327
- loginAttempts.delete(ip)
328
- issueSession(res)
329
- if (isForm) {
330
- res.writeHead(303, { location: '/', 'cache-control': 'no-store' })
331
- return res.end()
332
- }
333
- return sendJson(res, 200, { ok: true, redirect: '/' })
334
- }
335
- if (url.pathname === LOGOUT_PATH) {
336
- dropSession(req, res)
337
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
338
- return res.end(logoutPageHtml())
339
- }
340
-
341
- if (!authorize(req)) {
342
- // 浏览器页面访问 → 跳登录页;接口/静态资源请求 → 401(登录后由页面自身发起)。
343
- if (req.method === 'GET' && String(req.headers.accept || '').includes('text/html')) {
344
- res.writeHead(302, { location: LOGIN_PATH, 'cache-control': 'no-store' })
345
- return res.end()
346
- }
347
- return sendJson(res, 401, { ok: false, error: '未登录或会话已过期,请重新登录。' })
348
- }
349
- if (url.pathname === COMPAT_PATH && req.method === 'GET') {
350
- res.writeHead(200, {
351
- 'content-type': 'text/javascript; charset=utf-8',
352
- 'cache-control': 'no-store',
353
- 'x-content-type-options': 'nosniff',
354
- })
355
- return res.end(MOBILE_COMPAT_JS)
356
- }
357
- return proxyHttp(req, res)
358
- })
359
-
360
- server.on('upgrade', (req, socket, head) => {
361
- const authorized = authorize(req)
362
- if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
363
- const connection = { cookie: authorized.cookie, upstreamSocket: null }
364
- upgradedSockets.set(socket, connection)
365
- const forgetSocket = () => upgradedSockets.delete(socket)
366
- socket.once('close', forgetSocket)
367
- const upstream = httpRequest({
368
- hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
369
- headers: proxyHeaders(req),
370
- })
371
- upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
372
- connection.upstreamSocket = upstreamSocket
373
- writeUpgradeHead(socket, response)
374
- if (head && head.length) upstreamSocket.write(head)
375
- if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
376
- socket.once('close', () => upstreamSocket.destroy())
377
- upstreamSocket.once('close', () => socket.destroy())
378
- socket.pipe(upstreamSocket).pipe(socket)
379
- })
380
- upstream.on('response', (response) => {
381
- socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
382
- })
383
- upstream.on('error', () => socket.destroy())
384
- upstream.end()
385
- })
386
- server.once('error', reject)
387
- server.listen(port, '0.0.0.0', () => {
388
- server.removeListener('error', reject)
389
- const listened = server.address()
390
- const listenedPort = listened && typeof listened === 'object' ? listened.port : port
391
- resolve({
392
- port: listenedPort,
393
- addresses: lanAddresses(),
394
- revokeAllSessions() {
395
- sessions.clear()
396
- for (const [socket, value] of upgradedSockets) {
397
- if (value.upstreamSocket) value.upstreamSocket.destroy()
398
- socket.destroy()
399
- }
400
- upgradedSockets.clear()
401
- },
402
- close() {
403
- clearInterval(purgeTimer)
404
- sessions.clear()
405
- for (const [socket, value] of upgradedSockets) {
406
- if (value.upstreamSocket) value.upstreamSocket.destroy()
407
- socket.destroy()
408
- }
409
- upgradedSockets.clear()
410
- return new Promise((done) => server.close(() => done()))
411
- },
412
- })
413
- })
414
- })
415
- }
1
+ // dsh-dock · 远程访问安全网关
2
+ //
3
+ // 主 DSH 实例只监听 127.0.0.1(结构上不存在无登录的远程路径);局域网/虚拟网设备
4
+ // 统一经本网关访问:未登录一律跳转登录页(账号 + 密码),登录成功发放 HttpOnly
5
+ // 会话 Cookie(7 天滑动过期),支持退出登录;登录失败按来源 IP 限速。上游即 DSH
6
+ // 主实例:绑定回环时把 Host/Origin 改写为回环(回环恒被信任),已绑 0.0.0.0 时
7
+ // 透传以走局域网信任派生。宿主可注入 mintUpstreamCookie:服务端兑换 dsh web 的
8
+ // 启动令牌会话并在代理时自动附带(含 WebSocket 升级),远程浏览器只需账号密码。
9
+ // HTML 响应注入远程标记(window.__DSH_REMOTE__,供面板显示退出按钮)与
10
+ // crypto.randomUUID 兜底脚本引用。
11
+ import { createServer, request as httpRequest } from 'node:http'
12
+ import { networkInterfaces } from 'node:os'
13
+ import { randomBytes } from 'node:crypto'
14
+
15
+ const COOKIE_NAME = 'dsh_remote_session'
16
+ const LOGIN_PATH = '/__dsh_auth/login'
17
+ const LOGOUT_PATH = '/__dsh_auth/logout'
18
+ const HEALTH_PATH = '/__dsh_auth/health'
19
+ const COMPAT_PATH = '/__dsh_mobile/compat.js'
20
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
21
+ const MAX_LOGIN_BODY = 1024
22
+ const MAX_HTML_BYTES = 2 * 1024 * 1024
23
+ const LOGIN_WINDOW_MS = 10 * 60 * 1000
24
+ const MAX_LOGIN_ATTEMPTS = 10
25
+
26
+ // 远程设置面补丁:DSH 客户端按"页面主机名是否回环"决定设置镜像模式(非回环
27
+ // 构造 memory 镜像、永不读档,设置/模型页全部显示 unavailable)。而服务端信任栏
28
+ // 只看 Host/Origin——经本网关(登录后)的请求早已映射为回环信任,特权接口实际
29
+ // 全部放行。唯一拦住手机用户的只剩客户端这一处自我判定,所以对本 bundle 做定点
30
+ // 改写(判定恒真)。上游升级改了片段则原样透传(退化为官方行为,不影响其他功能)。
31
+ const CONNECTION_BUNDLE_PREFIX = '/plugins/@deepseek-ai/dsh-client-connection/client.js'
32
+ const CONNECTION_ISLOOPBACK_SNIPPET = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)'
33
+ const CONNECTION_ISLOOPBACK_PATCHED = 'isLoopback: !0 /* dsh-dock remote gateway */'
34
+ const MAX_ASSET_BYTES = 4 * 1024 * 1024
35
+ const PATCH_CACHE_LIMIT = 16
36
+
37
+ // LAN HTTP is not a browser secure context. Some browsers therefore expose
38
+ // crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
39
+ // connection/workspace clients call randomUUID() directly. ES5, precondition-free
40
+ // fallback: defines randomUUID (and a getRandomValues last resort) as both an own
41
+ // property and on Crypto.prototype. Injected before every DSH bootstrap script.
42
+ const MOBILE_COMPAT_JS = `(function(){var g=typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof self!=='undefined'?self:undefined));if(!g)return;if(!g.crypto){try{g.crypto={}}catch(e){return}}var c=g.crypto;var nativeRng=typeof c.getRandomValues==='function'?c.getRandomValues.bind(c):null;function rng(bytes){if(nativeRng){nativeRng(bytes);return bytes}for(var i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256)&255;return bytes}function uuid(){var b=rng(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h=[];for(var i=0;i<16;i++)h.push((b[i]+256).toString(16).slice(1));return h.slice(0,4).join('')+'-'+h.slice(4,6).join('')+'-'+h.slice(6,8).join('')+'-'+h.slice(8,10).join('')+'-'+h.slice(10).join('')}function fill(array){rng(array);return array}function define(obj,name,value){if(!obj||typeof obj[name]==='function')return;try{Object.defineProperty(obj,name,{value:value,configurable:true})}catch(e){try{obj[name]=value}catch(e2){}}}define(c,'randomUUID',uuid);if(!nativeRng)define(c,'getRandomValues',fill);define(g.Crypto&&g.Crypto.prototype||null,'randomUUID',uuid);if(!nativeRng)define(g.Crypto&&g.Crypto.prototype||null,'getRandomValues',fill);if(typeof c.randomUUID!=='function'){var fresh={getRandomValues:fill,randomUUID:uuid};if(c.subtle)fresh.subtle=c.subtle;var keyOrigin=Object.create(null);for(var k in c){try{keyOrigin[k]=c[k]}catch(e3){}}for(var k2 in keyOrigin){if(typeof fresh[k2]==='undefined')fresh[k2]=keyOrigin[k2]}try{Object.defineProperty(g,'crypto',{value:fresh,configurable:true})}catch(e4){try{g.crypto=fresh}catch(e5){}}}})()`
43
+
44
+ function secret() { return randomBytes(32).toString('base64url') }
45
+ function now() { return Date.now() }
46
+ function isPrivateIpv4(ip) {
47
+ const p = String(ip).split('.').map(Number)
48
+ return p.length === 4 && (p[0] === 10 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168))
49
+ }
50
+ function interfacePriority(name) {
51
+ if (/^en0$/.test(name)) return 100
52
+ if (/^en\d+$/.test(name)) return 90
53
+ if (/^(eth|enp|wlan|wl)/.test(name)) return 80
54
+ if (/^bridge/.test(name)) return 20
55
+ if (/^(utun|tun|tap)/.test(name)) return 10
56
+ return 50
57
+ }
58
+
59
+ /** 当前可用于手机连接的非回环 IPv4,私网地址优先。 */
60
+ export function lanAddresses() {
61
+ const rows = []
62
+ const table = networkInterfaces()
63
+ for (const [name, list] of Object.entries(table)) {
64
+ for (const item of list || []) {
65
+ if (!item || item.internal || item.family !== 'IPv4') continue
66
+ rows.push({ address: item.address, interface: name, private: isPrivateIpv4(item.address) })
67
+ }
68
+ }
69
+ return rows.sort((a, b) => Number(b.private) - Number(a.private)
70
+ || interfacePriority(b.interface) - interfacePriority(a.interface)
71
+ || a.interface.localeCompare(b.interface))
72
+ }
73
+
74
+ function parseCookies(header) {
75
+ const result = {}
76
+ for (const part of String(header || '').split(';')) {
77
+ const at = part.indexOf('=')
78
+ if (at <= 0) continue
79
+ result[part.slice(0, at).trim()] = part.slice(at + 1).trim()
80
+ }
81
+ return result
82
+ }
83
+ function sendJson(res, status, value, headers = {}) {
84
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
85
+ res.end(JSON.stringify(value))
86
+ }
87
+ function readSmallBody(req) {
88
+ return new Promise((resolve, reject) => {
89
+ let size = 0
90
+ const chunks = []
91
+ req.on('data', (chunk) => {
92
+ size += chunk.length
93
+ if (size > MAX_LOGIN_BODY) { reject(new Error('请求过大')); req.destroy(); return }
94
+ chunks.push(chunk)
95
+ })
96
+ req.on('end', () => resolve(Buffer.concat(chunks)))
97
+ req.on('error', reject)
98
+ })
99
+ }
100
+ // 登录页表单是原生 POST(application/x-www-form-urlencoded);面板/脚本走 JSON。
101
+ // 表单流的成功/失败都用 303 重定向表达(失败回到 ?e=1 错误态登录页),JSON 流保持
102
+ // JSON 响应(200/401/400),两种客户端互不干扰。
103
+ const PAGE_STYLE = `*{box-sizing:border-box}html{font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;color-scheme:dark light;height:100%}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:radial-gradient(1200px 600px at 20% -10%,#1d4ed81f,transparent),radial-gradient(900px 500px at 110% 110%,#0ea5e921,transparent),#0f1115;color:#eef2f8}@media(prefers-color-scheme:light){body{background:radial-gradient(1200px 600px at 20% -10%,#3b82f614,transparent),radial-gradient(900px 500px at 110% 110%,#0ea5e918,transparent),#eef1f6;color:#16181d}}.card{width:min(400px,calc(100vw - 32px));padding:32px 28px;border:1px solid #ffffff14;border-radius:18px;background:#161a22e6;box-shadow:0 24px 80px #00000059;backdrop-filter:blur(10px)}@media(prefers-color-scheme:light){.card{background:#ffffffd9;border-color:#00000014;box-shadow:0 24px 70px #94a3b840}}.brand{display:flex;align-items:center;gap:12px;margin-bottom:22px}.brand .icon{width:44px;height:44px;flex:none;display:grid;place-items:center;border-radius:13px;background:linear-gradient(135deg,#38bdf8,#6366f1);color:#fff;box-shadow:0 8px 24px #38bdf840}.brand .icon svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.brand h1{margin:0;font-size:18px;letter-spacing:.01em}.brand small{display:block;margin-top:2px;color:#8b93a3;font-size:12px;font-weight:400}label{display:block;margin-bottom:14px;font-size:13px;font-weight:600;color:#aab2c0}@media(prefers-color-scheme:light){label{color:#586173}}input{width:100%;margin-top:6px;padding:11px 13px;border:1px solid #ffffff1f;border-radius:11px;background:#0d1016;color:inherit;font:inherit;font-size:15px;outline:none;transition:border-color .15s,box-shadow .15s}@media(prefers-color-scheme:light){input{background:#f4f6fa;border-color:#0000001a}}input:focus{border-color:#38bdf8;box-shadow:0 0 0 3px #38bdf82e}button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;background:linear-gradient(135deg,#38bdf8,#6366f1);color:#fff;font:inherit;font-size:15px;font-weight:700;cursor:pointer;transition:filter .15s,transform .1s}button:hover{filter:brightness(1.07)}button:active{transform:scale(.99)}.error{margin:-4px 0 12px;padding:9px 12px;border-radius:10px;background:#ef44441f;color:#ff9d9d;font-size:13px;line-height:1.5}@media(prefers-color-scheme:light){.error{color:#c23434}}.noerror{display:none}.hint{margin:16px 0 0;color:#8b93a3;font-size:12px;line-height:1.6;text-align:center}.done{text-align:center}.done .ok{width:52px;height:52px;margin:4px auto 14px;display:grid;place-items:center;border-radius:50%;background:#22c55e26;color:#4ade80}.done .ok svg{width:26px;height:26px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}.done p{margin:0 0 18px;color:#8b93a3;font-size:13px}`
104
+ const PAGE_ICON = `<span class="icon"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l2-2a5 5 0 0 0-7.07-7.07l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.54-.54l-2 2a5 5 0 0 0 7.07 7.07l1.14-1.14"/></svg></span>`
105
+
106
+ function loginPageHtml(withError) {
107
+ const errorRow = withError
108
+ ? `<div class="error" role="alert">账号或密码不正确,请重试。</div>`
109
+ : `<div class="noerror"></div>`
110
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>DSH 远程访问</title><style>${PAGE_STYLE}</style></head><body><main class="card"><form method="POST" action="/__dsh_auth/login"><div class="brand">${PAGE_ICON}<div><h1>DSH 远程访问</h1><small>登录后可使用完整功能</small></div></div>${errorRow}<label>账号<input name="username" autocomplete="username" autofocus required></label><label>密码<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">登 录</button><p class="hint">仅限授权设备登录 · 会话 7 天内免重复登录</p></form></main></body></html>`
111
+ }
112
+
113
+ function logoutPageHtml() {
114
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"><title>已退出 · DSH 远程访问</title><meta http-equiv="refresh" content="2;url=/__dsh_auth/login"><style>${PAGE_STYLE}</style></head><body><main class="card done"><div class="ok"><svg viewBox="0 0 24 24"><path d="m5 12 4.2 4.2L19 6.5"/></svg></div><p>已退出登录。正在返回登录页…</p><button type="button" onclick="location.replace('/__dsh_auth/login')">立即返回</button></main></body></html>`
115
+ }
116
+ function passthroughProxyHeaders(headers) {
117
+ // 上游绑定 0.0.0.0(服务器模式):绑定即派生局域网 IP 信任(trustedHosts),
118
+ // 信任栏阻(api-request-trust)校验的是请求自带的 Host/Origin 一致性——原样透传
119
+ // Host、Origin 与 Cookie 即可通过;仅在 HTML 注入兼容层需要时去掉 accept-encoding。
120
+ return { ...headers }
121
+ }
122
+
123
+ function spoofLoopbackHeaders(headers, loopbackAuthority) {
124
+ // 上游仅绑回环(未开服务器模式):局域网地址不在信任列表,必须把 Host/Origin
125
+ // 一并改写成回环(回环恒被信任)。Cookie 仍原样透传(上游无 Cookie 需求)。
126
+ const next = { ...headers, host: loopbackAuthority }
127
+ if (next.origin) next.origin = 'http://' + loopbackAuthority
128
+ // HTML must stay uncompressed so the proxy can inject the early compatibility script.
129
+ delete next['accept-encoding']
130
+ return next
131
+ }
132
+
133
+ function htmlInjectionHeaders(headers) {
134
+ // HTML must stay uncompressed so the proxy can inject the early compatibility script.
135
+ const next = { ...headers }
136
+ delete next['accept-encoding']
137
+ return next
138
+ }
139
+ function injectMobileCompat(html) {
140
+ if (html.includes('data-dsh-mobile-compat')) return html
141
+ const match = /<head(?:\s[^>]*)?>/i.exec(html)
142
+ const script = `<script data-dsh-mobile-compat src="${COMPAT_PATH}"></script>`
143
+ if (!match) return script + html
144
+ const at = match.index + match[0].length
145
+ return html.slice(0, at) + script + html.slice(at)
146
+ }
147
+ function writeUpgradeHead(socket, response) {
148
+ const lines = [`HTTP/1.1 ${response.statusCode || 101} ${response.statusMessage || 'Switching Protocols'}`]
149
+ for (let i = 0; i < response.rawHeaders.length; i += 2) lines.push(response.rawHeaders[i] + ': ' + response.rawHeaders[i + 1])
150
+ socket.write(lines.join('\r\n') + '\r\n\r\n')
151
+ }
152
+
153
+ /**
154
+ * 开启需要账号密码登录的远程访问网关。verifyLogin(username, password) 由宿主注入
155
+ * (读 settings 中的加盐哈希做常量时间比对);会话 Cookie 7 天滑动过期;
156
+ * revokeAllSessions() 在账号密码变更时作废所有已登录设备。
157
+ */
158
+ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort, spoofLoopback = false, verifyLogin, mintUpstreamCookie, sessionTtlMs = SESSION_TTL_MS }) {
159
+ return new Promise((resolve, reject) => {
160
+ const sessions = new Map() // cookie secret -> { expiresAt, seenAt }
161
+ const upgradedSockets = new Map() // client socket -> { cookie, upstreamSocket }
162
+ const loginAttempts = new Map() // ip -> { count, firstAt }
163
+ const patchedBundles = new Map() // 上游 client.js URL(含 rev)-> 改写后字节
164
+ const loopbackAuthority = '127.0.0.1:' + upstreamPort
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
+
206
+ function purge() {
207
+ const stamp = now()
208
+ const expired = []
209
+ for (const [key, value] of sessions) if (value.expiresAt <= stamp) { sessions.delete(key); expired.push(key) }
210
+ for (const [key, item] of loginAttempts) if (stamp - item.firstAt > LOGIN_WINDOW_MS) loginAttempts.delete(key)
211
+ if (expired.length) {
212
+ for (const [socket, value] of upgradedSockets) {
213
+ if (expired.includes(value.cookie)) {
214
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
215
+ socket.destroy()
216
+ }
217
+ }
218
+ }
219
+ }
220
+ const purgeTimer = setInterval(purge, 60 * 1000)
221
+
222
+ function sessionCookieOf(req) {
223
+ return parseCookies(req.headers.cookie)[COOKIE_NAME] || null
224
+ }
225
+ function authorize(req) {
226
+ purge()
227
+ const cookie = sessionCookieOf(req)
228
+ const session = cookie ? sessions.get(cookie) : null
229
+ if (!session) return null
230
+ session.expiresAt = now() + sessionTtlMs
231
+ session.seenAt = now()
232
+ return { cookie }
233
+ }
234
+ function loginAllowed(ip) {
235
+ purge()
236
+ const item = loginAttempts.get(ip)
237
+ return !item || now() - item.firstAt > LOGIN_WINDOW_MS || item.count < MAX_LOGIN_ATTEMPTS
238
+ }
239
+ function recordLoginFail(ip) {
240
+ const item = loginAttempts.get(ip) || { count: 0, firstAt: now() }
241
+ if (now() - item.firstAt > LOGIN_WINDOW_MS) { item.count = 0; item.firstAt = now() }
242
+ item.count++
243
+ loginAttempts.set(ip, item)
244
+ }
245
+ function issueSession(res) {
246
+ const cookie = secret()
247
+ sessions.set(cookie, { expiresAt: now() + sessionTtlMs, seenAt: now() })
248
+ res.setHeader('set-cookie', `${COOKIE_NAME}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(sessionTtlMs / 1000)}`)
249
+ }
250
+ function dropSession(req, res) {
251
+ const cookie = sessionCookieOf(req)
252
+ if (cookie) sessions.delete(cookie)
253
+ res.setHeader('set-cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`)
254
+ }
255
+ function proxyHeaders(req) {
256
+ const contentTypeHint = String(req.headers['content-type'] || '')
257
+ const wantsHtml = req.method === 'GET' && !contentTypeHint
258
+ // 先按上游信任形态决定 Host/Origin 策略,再叠加 HTML 注入所需的去压缩。
259
+ const next = spoofLoopback
260
+ ? spoofLoopbackHeaders(req.headers, loopbackAuthority)
261
+ : passthroughProxyHeaders(req.headers)
262
+ if (wantsHtml) delete next['accept-encoding']
263
+ return next
264
+ }
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)
293
+ return
294
+ }
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)
324
+ }
325
+ res.writeHead(upstreamRes.statusCode || 502, headers)
326
+ res.end(injectMobileCompat(body.toString('utf8')))
327
+ })
328
+ })
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)) })
337
+ else res.destroy(error)
338
+ }
339
+ }
340
+
341
+ const server = createServer(async (req, res) => {
342
+ const url = new URL(req.url || '/', 'http://remote.internal')
343
+ const ip = (req.socket && req.socket.remoteAddress) || 'unknown'
344
+
345
+ if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
346
+
347
+ if (url.pathname === LOGIN_PATH && req.method === 'GET') {
348
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
349
+ return res.end(loginPageHtml(url.searchParams.get('e') === '1'))
350
+ }
351
+ if (url.pathname === LOGIN_PATH && req.method === 'POST') {
352
+ if (!loginAllowed(ip)) return sendJson(res, 429, { ok: false, error: '尝试次数过多,请 10 分钟后再试。' })
353
+ const isForm = /urlencoded/i.test(String(req.headers['content-type'] || ''))
354
+ let fields = null
355
+ try {
356
+ const raw = (await readSmallBody(req)).toString('utf8')
357
+ if (isForm) {
358
+ const form = new URLSearchParams(raw)
359
+ fields = { username: form.get('username'), password: form.get('password') }
360
+ } else {
361
+ const parsed = JSON.parse(raw || '{}')
362
+ fields = { username: parsed && parsed.username, password: parsed && parsed.password }
363
+ }
364
+ } catch { fields = null }
365
+ const username = String(fields && fields.username || '').trim()
366
+ const password = String(fields && fields.password || '')
367
+ let ok = false
368
+ try { ok = Boolean(fields && verifyLogin && await verifyLogin(username, password)) } catch { ok = false }
369
+ if (!ok) {
370
+ recordLoginFail(ip)
371
+ if (isForm) {
372
+ res.writeHead(303, { location: LOGIN_PATH + '?e=1', 'cache-control': 'no-store' })
373
+ return res.end()
374
+ }
375
+ return sendJson(res, fields ? 401 : 400, { ok: false, error: fields ? '账号或密码不正确。' : '请求格式错误' })
376
+ }
377
+ loginAttempts.delete(ip)
378
+ issueSession(res)
379
+ if (isForm) {
380
+ res.writeHead(303, { location: '/', 'cache-control': 'no-store' })
381
+ return res.end()
382
+ }
383
+ return sendJson(res, 200, { ok: true, redirect: '/' })
384
+ }
385
+ if (url.pathname === LOGOUT_PATH) {
386
+ dropSession(req, res)
387
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
388
+ return res.end(logoutPageHtml())
389
+ }
390
+
391
+ if (!authorize(req)) {
392
+ // 浏览器页面访问 → 跳登录页;接口/静态资源请求 → 401(登录后由页面自身发起)。
393
+ if (req.method === 'GET' && String(req.headers.accept || '').includes('text/html')) {
394
+ res.writeHead(302, { location: LOGIN_PATH, 'cache-control': 'no-store' })
395
+ return res.end()
396
+ }
397
+ return sendJson(res, 401, { ok: false, error: '未登录或会话已过期,请重新登录。' })
398
+ }
399
+ if (url.pathname === COMPAT_PATH && req.method === 'GET') {
400
+ res.writeHead(200, {
401
+ 'content-type': 'text/javascript; charset=utf-8',
402
+ 'cache-control': 'no-store',
403
+ 'x-content-type-options': 'nosniff',
404
+ })
405
+ return res.end(MOBILE_COMPAT_JS)
406
+ }
407
+ return proxyHttp(req, res)
408
+ })
409
+
410
+ server.on('upgrade', async (req, socket, head) => {
411
+ const authorized = authorize(req)
412
+ if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
413
+ const connection = { cookie: authorized.cookie, upstreamSocket: null }
414
+ upgradedSockets.set(socket, connection)
415
+ const forgetSocket = () => upgradedSockets.delete(socket)
416
+ socket.once('close', forgetSocket)
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
+ }
427
+ upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
428
+ connection.upstreamSocket = upstreamSocket
429
+ writeUpgradeHead(socket, response)
430
+ if (head && head.length) upstreamSocket.write(head)
431
+ if (upstreamHead && upstreamHead.length) socket.write(upstreamHead)
432
+ socket.once('close', () => upstreamSocket.destroy())
433
+ upstreamSocket.once('close', () => socket.destroy())
434
+ socket.pipe(upstreamSocket).pipe(socket)
435
+ })
436
+ upstream.on('response', (response) => {
437
+ if (response.statusCode === 401) dropUpstreamCookie()
438
+ socket.end(`HTTP/1.1 ${response.statusCode || 502} ${response.statusMessage || 'Bad Gateway'}\r\nConnection: close\r\n\r\n`)
439
+ })
440
+ upstream.on('error', () => socket.destroy())
441
+ upstream.end()
442
+ })
443
+ server.once('error', reject)
444
+ server.listen(port, '0.0.0.0', () => {
445
+ server.removeListener('error', reject)
446
+ const listened = server.address()
447
+ const listenedPort = listened && typeof listened === 'object' ? listened.port : port
448
+ resolve({
449
+ port: listenedPort,
450
+ addresses: lanAddresses(),
451
+ revokeAllSessions() {
452
+ sessions.clear()
453
+ for (const [socket, value] of upgradedSockets) {
454
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
455
+ socket.destroy()
456
+ }
457
+ upgradedSockets.clear()
458
+ },
459
+ close() {
460
+ clearInterval(purgeTimer)
461
+ sessions.clear()
462
+ for (const [socket, value] of upgradedSockets) {
463
+ if (value.upstreamSocket) value.upstreamSocket.destroy()
464
+ socket.destroy()
465
+ }
466
+ upgradedSockets.clear()
467
+ return new Promise((done) => server.close(() => done()))
468
+ },
469
+ })
470
+ })
471
+ })
472
+ }