dsh-dock 0.9.6 → 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.
- package/LICENSE +18 -18
- package/README.md +325 -325
- package/client.js +42 -8
- package/cordis.patch.yml +2 -2
- package/features/animation/host.js +660 -660
- package/features/animation/view.jsx +2247 -2247
- package/features/balance/host.js +494 -494
- package/features/balance/view.js +351 -351
- package/features/games/games/breakout.jsx +227 -227
- package/features/games/games/gomoku.jsx +244 -244
- package/features/games/games/racer.jsx +169 -169
- package/features/games/games/shared.jsx +118 -118
- package/features/games/games/snake.jsx +144 -144
- package/features/games/games/sokoban.jsx +180 -180
- package/features/games/games/tank.jsx +128 -128
- package/features/games/games/tetris.jsx +308 -308
- package/features/games/games/xiangqi.jsx +277 -277
- package/features/games/tank-core.js +152 -152
- package/features/games/view.jsx +639 -639
- package/features/games/xiangqi-core.js +299 -299
- package/features/heartbeat/view.js +35 -35
- package/features/mobile-relay/gateway.js +472 -472
- package/features/mobile-relay/host.js +570 -569
- package/features/mobile-relay/view.jsx +300 -300
- package/features/modelconfig/host.js +403 -403
- package/features/modelconfig/view.js +490 -490
- package/features/theme/view.js +61 -61
- package/features/tokenlog/host.js +678 -678
- package/features/tokenlog/view.jsx +561 -561
- package/features/visionproxy/host.js +315 -315
- package/index.js +175 -118
- package/package.json +59 -59
- package/src/client.jsx +668 -668
- package/src/host-core.js +98 -95
- package/src/shared.js +167 -130
|
@@ -1,472 +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
|
-
// 透传以走局域网信任派生。宿主可注入 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
|
-
}
|
|
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
|
+
}
|