dsh-dock 0.8.0 → 0.9.1
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.md +136 -0
- package/client.js +2607 -187
- package/features/animation/view.jsx +39 -39
- package/features/balance/view.js +19 -17
- package/features/games/view.jsx +4 -4
- package/features/mobile-relay/gateway.js +217 -81
- package/features/mobile-relay/host.js +305 -230
- package/features/mobile-relay/view.jsx +107 -140
- package/features/modelconfig/view.js +4 -4
- package/features/tokenlog/view.jsx +8 -8
- package/index.js +20 -1
- package/package.json +2 -1
- package/src/client.jsx +22 -8
- package/src/host-core.js +7 -1
|
@@ -1,25 +1,43 @@
|
|
|
1
|
-
// dsh-dock ·
|
|
1
|
+
// dsh-dock · 远程访问安全网关
|
|
2
2
|
//
|
|
3
|
-
// DSH
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 兜底脚本引用。
|
|
6
9
|
import { createServer, request as httpRequest } from 'node:http'
|
|
7
10
|
import { networkInterfaces } from 'node:os'
|
|
8
11
|
import { randomBytes } from 'node:crypto'
|
|
9
12
|
|
|
10
|
-
const COOKIE_NAME = '
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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'
|
|
13
17
|
const COMPAT_PATH = '/__dsh_mobile/compat.js'
|
|
14
|
-
const SESSION_TTL_MS =
|
|
15
|
-
const
|
|
18
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
19
|
+
const MAX_LOGIN_BODY = 1024
|
|
16
20
|
const MAX_HTML_BYTES = 2 * 1024 * 1024
|
|
21
|
+
const LOGIN_WINDOW_MS = 10 * 60 * 1000
|
|
22
|
+
const MAX_LOGIN_ATTEMPTS = 10
|
|
17
23
|
|
|
18
|
-
//
|
|
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
|
|
19
36
|
// crypto.getRandomValues() but omit crypto.randomUUID(), while the official DSH
|
|
20
|
-
// connection/workspace clients call randomUUID() directly.
|
|
21
|
-
//
|
|
22
|
-
|
|
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){}}}})()`
|
|
23
41
|
|
|
24
42
|
function secret() { return randomBytes(32).toString('base64url') }
|
|
25
43
|
function now() { return Date.now() }
|
|
@@ -64,31 +82,56 @@ function sendJson(res, status, value, headers = {}) {
|
|
|
64
82
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers })
|
|
65
83
|
res.end(JSON.stringify(value))
|
|
66
84
|
}
|
|
67
|
-
function
|
|
85
|
+
function readSmallBody(req) {
|
|
68
86
|
return new Promise((resolve, reject) => {
|
|
69
87
|
let size = 0
|
|
70
88
|
const chunks = []
|
|
71
89
|
req.on('data', (chunk) => {
|
|
72
90
|
size += chunk.length
|
|
73
|
-
if (size >
|
|
91
|
+
if (size > MAX_LOGIN_BODY) { reject(new Error('请求过大')); req.destroy(); return }
|
|
74
92
|
chunks.push(chunk)
|
|
75
93
|
})
|
|
76
|
-
req.on('end', () =>
|
|
77
|
-
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) }
|
|
78
|
-
catch { reject(new Error('请求格式错误')) }
|
|
79
|
-
})
|
|
94
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
80
95
|
req.on('error', reject)
|
|
81
96
|
})
|
|
82
97
|
}
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
85
129
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
delete next.cookie
|
|
130
|
+
|
|
131
|
+
function htmlInjectionHeaders(headers) {
|
|
89
132
|
// HTML must stay uncompressed so the proxy can inject the early compatibility script.
|
|
133
|
+
const next = { ...headers }
|
|
90
134
|
delete next['accept-encoding']
|
|
91
|
-
if (next.origin) next.origin = 'http://' + upstreamAuthority
|
|
92
135
|
return next
|
|
93
136
|
}
|
|
94
137
|
function injectMobileCompat(html) {
|
|
@@ -106,104 +149,203 @@ function writeUpgradeHead(socket, response) {
|
|
|
106
149
|
}
|
|
107
150
|
|
|
108
151
|
/**
|
|
109
|
-
*
|
|
152
|
+
* 开启需要账号密码登录的远程访问网关。verifyLogin(username, password) 由宿主注入
|
|
153
|
+
* (读 settings 中的加盐哈希做常量时间比对);会话 Cookie 7 天滑动过期;
|
|
154
|
+
* revokeAllSessions() 在账号密码变更时作废所有已登录设备。
|
|
110
155
|
*/
|
|
111
|
-
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort }) {
|
|
156
|
+
export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', upstreamPort, spoofLoopback = false, verifyLogin, sessionTtlMs = SESSION_TTL_MS }) {
|
|
112
157
|
return new Promise((resolve, reject) => {
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
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
|
|
117
163
|
|
|
118
164
|
function purge() {
|
|
119
165
|
const stamp = now()
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
for (const [key,
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
if (expiredSessions.size) {
|
|
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) {
|
|
126
170
|
for (const [socket, value] of upgradedSockets) {
|
|
127
|
-
if (
|
|
171
|
+
if (expired.includes(value.cookie)) {
|
|
128
172
|
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
129
173
|
socket.destroy()
|
|
130
174
|
}
|
|
131
175
|
}
|
|
132
176
|
}
|
|
133
177
|
}
|
|
178
|
+
const purgeTimer = setInterval(purge, 60 * 1000)
|
|
179
|
+
|
|
180
|
+
function sessionCookieOf(req) {
|
|
181
|
+
return parseCookies(req.headers.cookie)[COOKIE_NAME] || null
|
|
182
|
+
}
|
|
134
183
|
function authorize(req) {
|
|
135
184
|
purge()
|
|
136
|
-
const
|
|
137
|
-
const session =
|
|
185
|
+
const cookie = sessionCookieOf(req)
|
|
186
|
+
const session = cookie ? sessions.get(cookie) : null
|
|
138
187
|
if (!session) return null
|
|
188
|
+
session.expiresAt = now() + sessionTtlMs
|
|
139
189
|
session.seenAt = now()
|
|
140
|
-
return {
|
|
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
|
|
141
222
|
}
|
|
142
223
|
function proxyHttp(req, res) {
|
|
143
224
|
const upstream = httpRequest({
|
|
144
225
|
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
145
|
-
headers:
|
|
226
|
+
headers: proxyHeaders(req),
|
|
146
227
|
}, (upstreamRes) => {
|
|
228
|
+
// 浏览器侧中途断开时把上游响应读完丢弃(而非 destroy)——宿主对半路 RST
|
|
229
|
+
// 敏感,读完让上游 socket 干净收尾复用。缓冲分支天然读尽,无需处理。
|
|
230
|
+
if (upstreamRes) upstreamRes.on('error', () => res.destroy())
|
|
147
231
|
const contentType = String(upstreamRes.headers['content-type'] || '').toLowerCase()
|
|
148
|
-
|
|
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() })
|
|
149
245
|
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers)
|
|
150
246
|
upstreamRes.pipe(res)
|
|
151
247
|
return
|
|
152
248
|
}
|
|
153
249
|
const chunks = []
|
|
154
250
|
let size = 0
|
|
251
|
+
const limit = isConnectionBundle ? MAX_ASSET_BYTES : MAX_HTML_BYTES
|
|
155
252
|
upstreamRes.on('data', (chunk) => {
|
|
156
253
|
size += chunk.length
|
|
157
|
-
if (size <=
|
|
254
|
+
if (size <= limit) chunks.push(chunk)
|
|
158
255
|
})
|
|
159
256
|
upstreamRes.on('end', () => {
|
|
160
|
-
if (size >
|
|
257
|
+
if (size > limit) {
|
|
161
258
|
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 页面过大,无法注入手机兼容层。' })
|
|
162
259
|
return
|
|
163
260
|
}
|
|
164
261
|
const headers = { ...upstreamRes.headers }
|
|
165
262
|
delete headers['content-length']
|
|
166
263
|
delete headers['content-encoding']
|
|
264
|
+
delete headers['transfer-encoding']
|
|
167
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
|
+
}
|
|
168
279
|
res.writeHead(upstreamRes.statusCode || 502, headers)
|
|
169
|
-
res.end(injectMobileCompat(
|
|
280
|
+
res.end(injectMobileCompat(body.toString('utf8')))
|
|
170
281
|
})
|
|
171
282
|
})
|
|
172
283
|
upstream.on('error', (error) => {
|
|
173
284
|
if (!res.headersSent) sendJson(res, 502, { ok: false, error: 'DSH 主服务连接失败:' + error.message })
|
|
174
285
|
else res.destroy(error)
|
|
175
286
|
})
|
|
287
|
+
req.on('error', () => upstream.destroy())
|
|
176
288
|
req.pipe(upstream)
|
|
177
289
|
}
|
|
178
290
|
|
|
179
291
|
const server = createServer(async (req, res) => {
|
|
180
|
-
const url = new URL(req.url || '/', 'http://
|
|
292
|
+
const url = new URL(req.url || '/', 'http://remote.internal')
|
|
293
|
+
const ip = (req.socket && req.socket.remoteAddress) || 'unknown'
|
|
294
|
+
|
|
181
295
|
if (url.pathname === HEALTH_PATH) return sendJson(res, 200, { ok: true })
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer',
|
|
187
|
-
})
|
|
188
|
-
return res.end(connectHtml())
|
|
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'))
|
|
189
300
|
}
|
|
190
|
-
if (url.pathname ===
|
|
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
|
|
191
305
|
try {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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()
|
|
204
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: '未登录或会话已过期,请重新登录。' })
|
|
205
348
|
}
|
|
206
|
-
if (!authorize(req)) return sendJson(res, 401, { ok: false, error: '手机连接未认证或已过期,请使用电脑端生成的接力链接重新进入。' })
|
|
207
349
|
if (url.pathname === COMPAT_PATH && req.method === 'GET') {
|
|
208
350
|
res.writeHead(200, {
|
|
209
351
|
'content-type': 'text/javascript; charset=utf-8',
|
|
@@ -218,13 +360,13 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
218
360
|
server.on('upgrade', (req, socket, head) => {
|
|
219
361
|
const authorized = authorize(req)
|
|
220
362
|
if (!authorized) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return }
|
|
221
|
-
const connection = {
|
|
363
|
+
const connection = { cookie: authorized.cookie, upstreamSocket: null }
|
|
222
364
|
upgradedSockets.set(socket, connection)
|
|
223
365
|
const forgetSocket = () => upgradedSockets.delete(socket)
|
|
224
366
|
socket.once('close', forgetSocket)
|
|
225
367
|
const upstream = httpRequest({
|
|
226
368
|
hostname: upstreamHost, port: upstreamPort, method: req.method, path: req.url,
|
|
227
|
-
headers:
|
|
369
|
+
headers: proxyHeaders(req),
|
|
228
370
|
})
|
|
229
371
|
upstream.on('upgrade', (response, upstreamSocket, upstreamHead) => {
|
|
230
372
|
connection.upstreamSocket = upstreamSocket
|
|
@@ -249,23 +391,17 @@ export function startProtectedLanGateway({ port, upstreamHost = '127.0.0.1', ups
|
|
|
249
391
|
resolve({
|
|
250
392
|
port: listenedPort,
|
|
251
393
|
addresses: lanAddresses(),
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
pending.set(token, { pairId, launch, expiresAt })
|
|
255
|
-
return token
|
|
256
|
-
},
|
|
257
|
-
revokePair(pairId) {
|
|
258
|
-
for (const [key, value] of pending) if (value.pairId === pairId) pending.delete(key)
|
|
259
|
-
for (const [key, value] of sessions) if (value.pairId === pairId) sessions.delete(key)
|
|
394
|
+
revokeAllSessions() {
|
|
395
|
+
sessions.clear()
|
|
260
396
|
for (const [socket, value] of upgradedSockets) {
|
|
261
|
-
if (value.pairId !== pairId) continue
|
|
262
397
|
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
263
398
|
socket.destroy()
|
|
264
399
|
}
|
|
400
|
+
upgradedSockets.clear()
|
|
265
401
|
},
|
|
266
|
-
purge,
|
|
267
402
|
close() {
|
|
268
|
-
|
|
403
|
+
clearInterval(purgeTimer)
|
|
404
|
+
sessions.clear()
|
|
269
405
|
for (const [socket, value] of upgradedSockets) {
|
|
270
406
|
if (value.upstreamSocket) value.upstreamSocket.destroy()
|
|
271
407
|
socket.destroy()
|