dsh-dock 0.8.0 → 0.9.0

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.
@@ -606,7 +606,7 @@ const baseCss = `
606
606
  .dgwin-picker{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;gap:10px;padding:13px;background:rgb(10 14 32 / .86);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow-y:auto;animation:dgentry-in .15s ease-out}.dgwin-picker-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.dgwin-picker-head>div{display:flex;flex-direction:column;gap:3px}.dgwin-picker-head b{font-size:14px}.dgwin-picker-head span{font-size:11px;color:#aab3e8}.dgwin-picker-close{cursor:pointer;border:none;background:transparent;color:#7d86c0;border-radius:8px;width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;flex:none}.dgwin-picker-close:hover{background:rgb(255 255 255 / .08);color:#e7ecff}.dgwin-picker .dgcov-grid{flex-direction:column;--dsw-alias-label-primary:#e7ecff;--dsw-alias-label-secondary:#aab3e8;--dsw-alias-border-l1:rgb(255 255 255 / .12)}.dgwin-picker .dgcov{min-width:0;background:rgb(255 255 255 / .04)}
607
607
  .dgwin-resize{position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;z-index:3;touch-action:none}.dgwin-resize::after{content:'';position:absolute;right:4px;bottom:4px;width:8px;height:8px;border-right:2px solid rgb(255 255 255 / .3);border-bottom:2px solid rgb(255 255 255 / .3);border-radius:0 0 3px 0}
608
608
  @keyframes dgentry-in{from{opacity:0}}
609
- @media (max-width:680px){.dgame{gap:12px;font-size:14px}.dgame-hero{padding:15px}.dgame-hero h2{font-size:20px}.dgame-planet{width:58px;height:58px}.dgame-planet::after{left:-12px;top:25px;width:80px;height:20px}.dgame-game{padding:12px}.dgame-dash-grid{grid-template-rows:repeat(5,48px)}.dgame-controls{grid-template-columns:1fr 1fr}.dgame-controls span{grid-column:1 / -1;grid-row:2}.dgame-reactor{gap:8px}.dgcov-grid{flex-direction:column}.dgcov{min-width:0}.dgfab{width:32px}.dgwin-title span,.dgwin-task{display:none}.dgwin-bar{gap:7px;padding:8px 10px}.dgwin-btn{padding:6px 8px}.dgwin-body{padding:10px}}
609
+ @media (max-width:680px){.dgame{gap:12px;font-size:14px}.dgame-hero{padding:15px}.dgame-hero h2{font-size:20px}.dgame-planet{width:58px;height:58px}.dgame-planet::after{left:-12px;top:25px;width:80px;height:20px}.dgame-game{padding:12px}.dgame-dash-grid{grid-template-rows:repeat(5,48px)}.dgame-controls{grid-template-columns:1fr 1fr}.dgame-controls span{grid-column:1 / -1;grid-row:2}.dgame-reactor{gap:8px}.dgcov-grid{flex-direction:column}.dgcov{min-width:0}.dgfab{width:32px;opacity:.72}.dgfab:hover{opacity:1}.dgwin-title span,.dgwin-task{display:none}.dgwin-bar{gap:7px;padding:8px 10px}.dgwin-btn{padding:6px 8px}.dgwin-body{padding:10px}}
610
610
  @media (prefers-reduced-motion:reduce){.dgame *,.dgcov *,.dgfab,.dgwin,.dgwin-picker *{animation:none!important;transition:none!important}}
611
611
  `;
612
612
 
@@ -1,25 +1,43 @@
1
- // dsh-dock · 手机接力局域网安全网关
1
+ // dsh-dock · 远程访问安全网关
2
2
  //
3
- // DSH 0.1.1-rc.2 有意拒绝直接绑定 0.0.0.0:浏览器表层尚无登录认证,直接开放
4
- // 会把代码执行能力交给同一网络里的任何人。本文件提供一个最小认证反向代理:主 DSH
5
- // 继续只监听 127.0.0.1,手机凭 256-bit 一次性链接换取 HttpOnly 会话后才能访问。
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 = 'dsh_mobile_session'
11
- const CONNECT_PATH = '/__dsh_mobile/connect'
12
- const HEALTH_PATH = '/__dsh_mobile/health'
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 = 12 * 60 * 60 * 1000
15
- const MAX_CONNECT_BODY = 4096
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
- // LAN HTTP is not a browser secure context. Some mobile browsers therefore expose
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. This tiny standards-compatible
21
- // UUID v4 fallback is injected before every DSH bootstrap script through the proxy only.
22
- const MOBILE_COMPAT_JS = `(()=>{const c=globalThis.crypto;if(!c||typeof c.randomUUID==='function'||typeof c.getRandomValues!=='function')return;const make=()=>{const b=new Uint8Array(16);c.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;const h=Array.from(b,x=>x.toString(16).padStart(2,'0')).join('');return h.slice(0,8)+'-'+h.slice(8,12)+'-'+h.slice(12,16)+'-'+h.slice(16,20)+'-'+h.slice(20)};try{Object.defineProperty(c,'randomUUID',{value:make,configurable:true})}catch{try{c.randomUUID=make}catch{}}})()`
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 readSmallJson(req) {
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 > MAX_CONNECT_BODY) { reject(new Error('请求过大')); req.destroy(); return }
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
- function connectHtml() {
84
- 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>html{font-family:system-ui,-apple-system,sans-serif;color-scheme:dark light}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom);background:#111318;color:#f4f6fa}.card{box-sizing:border-box;width:min(420px,calc(100vw - 32px));padding:28px;border:1px solid #30343d;border-radius:18px;background:#1c1f26;box-shadow:0 18px 60px #0007;text-align:center}.icon{width:48px;height:48px;margin:0 auto 16px;display:grid;place-items:center;border-radius:15px;background:#38bdf822;color:#38bdf8}.icon svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#abb1bd;line-height:1.6;font-size:14px}.error{margin-top:16px;padding:10px 12px;border-radius:10px;background:#ef44441a;color:#ff8b8b;text-align:left}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f1f4f8;color:#16181d}.card{background:#fff;border-color:#d8dde6}.card p{color:#586173}}</style></head><body><main class="card"><div class="icon" aria-hidden="true"><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></div><h1>正在安全连接</h1><p id="status">验证一次性链接并进入 DSH…</p><div id="error" class="error" role="alert" hidden></div></main><script>(()=>{const status=document.getElementById('status'),error=document.getElementById('error'),token=decodeURIComponent(location.hash.slice(1));history.replaceState(null,'',location.pathname);if(!token){status.textContent='连接链接不完整';error.hidden=false;error.textContent='请回到电脑端重新复制手机接力链接。';return}fetch(${JSON.stringify(CONNECT_PATH)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token})}).then(async r=>{const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok)throw new Error(d.error||'连接验证失败');status.textContent='验证成功,正在打开任务…';location.replace('/#dsh-mobile-relay='+encodeURIComponent(d.launch))}).catch(e=>{status.textContent='无法连接';error.hidden=false;error.textContent=e&&e.message?e.message:String(e)})})()</script></body></html>`
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
- function rewriteProxyHeaders(headers, upstreamAuthority) {
87
- const next = { ...headers, host: upstreamAuthority }
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
- * 开启一个需要一次性链接认证的局域网代理。返回的 issue() 生成只在 fragment 中传输的令牌。
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 pending = new Map() // gateway token -> { pairId, launch, expiresAt }
114
- const sessions = new Map() // cookie secret -> { pairId, expiresAt, seenAt }
115
- const upgradedSockets = new Map() // client socket -> { sessionId, pairId, upstreamSocket }
116
- const authority = upstreamHost + ':' + upstreamPort
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
- for (const [key, value] of pending) if (value.expiresAt <= stamp) pending.delete(key)
121
- const expiredSessions = new Set()
122
- for (const [key, value] of sessions) {
123
- if (value.expiresAt <= stamp) { sessions.delete(key); expiredSessions.add(key) }
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 (expiredSessions.has(value.sessionId)) {
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 value = parseCookies(req.headers.cookie)[COOKIE_NAME]
137
- const session = value ? sessions.get(value) : null
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 { ...session, sessionId: value }
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: rewriteProxyHeaders(req.headers, authority),
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
- if (!contentType.includes('text/html')) {
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 <= MAX_HTML_BYTES) chunks.push(chunk)
254
+ if (size <= limit) chunks.push(chunk)
158
255
  })
159
256
  upstreamRes.on('end', () => {
160
- if (size > MAX_HTML_BYTES) {
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(Buffer.concat(chunks).toString('utf8')))
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://dsh-mobile.internal')
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
- if (url.pathname === CONNECT_PATH && req.method === 'GET') {
183
- res.writeHead(200, {
184
- 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
185
- 'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
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 === CONNECT_PATH && req.method === 'POST') {
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 body = await readSmallJson(req)
193
- const token = String(body && body.token || '')
194
- const grant = pending.get(token)
195
- if (!grant || grant.expiresAt <= now()) return sendJson(res, 403, { ok: false, error: '一次性链接无效或已过期,请在电脑端重新开启。' })
196
- pending.delete(token)
197
- const sessionId = secret()
198
- sessions.set(sessionId, { pairId: grant.pairId, expiresAt: now() + SESSION_TTL_MS, seenAt: now() })
199
- return sendJson(res, 200, { ok: true, launch: grant.launch }, {
200
- 'set-cookie': `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/`,
201
- })
202
- } catch (error) {
203
- return sendJson(res, 400, { ok: false, error: error.message || String(error) })
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 = { sessionId: authorized.sessionId, pairId: authorized.pairId, upstreamSocket: null }
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: rewriteProxyHeaders(req.headers, authority),
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
- issue(pairId, launch, expiresAt) {
253
- const token = secret()
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
- pending.clear(); sessions.clear()
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()