dsh-pocket-relay 1.0.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.
package/lib/proxy.mjs ADDED
@@ -0,0 +1,1223 @@
1
+ // dsh-pocket 核心:Host/Origin 改写反向代理
2
+ //
3
+ // 为什么需要它:DSH 的 /api 浏览器信任栅栏只认 loopback(127.0.0.1)或
4
+ // `--trusted-host` 白名单(且官方禁了 0.0.0.0 绑定,防止把远程执行代码暴露给网络)。
5
+ // 本代理把入站请求的 Host / Origin 统一改写成 loopback 权威(127.0.0.1:3080),
6
+ // 转发给本机 dsh web——栅栏永远看到 loopback,于是:
7
+ // - 局域网:手机直接访问 http://<电脑IP>:端口
8
+ // - 公网:cloudflared 隧道指到本代理,任意域名都能进
9
+ // 都不需要改 dsh 的任何配置。
10
+ //
11
+ // 同步保证:普通请求与 WebSocket upgrade(/api/events.host 流式推送)都原样透传,
12
+ // 手机看到的界面与电脑完全一致、实时。
13
+
14
+ import { createServer } from 'node:http';
15
+ import { request as httpRequest } from 'node:http';
16
+ import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
17
+ import { createHash, timingSafeEqual } from 'node:crypto';
18
+
19
+ const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
20
+
21
+ /**
22
+ * 非安全上下文(http://<LAN-IP>:端口)里浏览器缺两个 API,由代理注入 polyfill
23
+ * (只在缺少时生效,不覆盖原生实现):
24
+ * 1. crypto.randomUUID——DSH 连接层 mint RPC id 用,缺失直接抛错;
25
+ * 2. AbortSignal.any(issue #53)——Android 厂商浏览器/WebView(Chrome < 116)
26
+ * 无原生实现,DSH 连接层发送消息会调 AbortSignal.any([...]),缺失则消息发不出。
27
+ * 带 data-dsh-pocket-polyfill 标记:注入判重用它,而不是搜索 "crypto.randomUUID"
28
+ * 字样(dsh 页面源码里可能恰好出现该字符串,导致误判为已注入而跳过)。
29
+ */
30
+ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();
31
+ !function(){try{if(self.AbortSignal&&!self.AbortSignal.any){self.AbortSignal.any=function(signals){var controller=new AbortController();var list=Array.from(signals||[]);var done=false;var handlers=list.map(function(signal){return function(){abort(signal);};});function cleanup(){for(var i=0;i<list.length;i++){try{list[i].removeEventListener('abort',handlers[i]);}catch(e){}}}function abort(signal){if(done)return;done=true;cleanup();try{controller.abort(signal.reason);}catch(e){controller.abort();}}for(var j=0;j<list.length;j++){var sig=list[j];if(sig.aborted){abort(sig);break;}sig.addEventListener('abort',handlers[j],{once:true});}return controller.signal;};}}catch(e){}}();
32
+ /* 注:曾用「全局 let location + Proxy」伪装 location.hostname 修 DSH isLoopback 判定
33
+ (issue #58:局域网访问模型设置页报 settings unavailable)——但 let location 全局
34
+ 词法绑定会让任何恰好顶层声明 location 的脚本(DSH 插件经典 script)SyntaxError 崩溃,
35
+ 导致会话列表不显示(实测 PAGEERROR: Identifier 'location' has already been declared)。
36
+ 已回退;该问题属 DSH 客户端限制(location.hostname 是 unforgeable 属性)无法安全绕过。*/</script>`;
37
+
38
+ /**
39
+ * issue #96:dsh 0.1.1-rc.2 起,`@deepseek-ai/dsh-client-connection` 的连接层改成
40
+ * const api = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
41
+ * 其中 transport = globalThis.__DSH_TRANSPORT__(由 DSH 宿主 web shell 注入,本仓库
42
+ * 全程不创建、不引用这个全局)。桌面直连 127.0.0.1:3080 时宿主给的 transport 带
43
+ * createApiClient;经本代理(局域网 / 隧道域名)访问时宿主给的 transport 不带该方法
44
+ * → 手机上一执行就 TypeError: transport?.createApiClient is not a function,整页崩。
45
+ *
46
+ * 兜底策略:只在该方法缺失时补一个返回 null 的实现。null 会触发宿主自己那条
47
+ * `?? new WebApiClient()` 兜底分支,等于让连接层回到旧版本(0.1.1-rc.2 之前)的
48
+ * 行为。宿主自己有实现时一律不覆盖。
49
+ *
50
+ * 这是 stopgap 不是根治:真正的契约缺口在 DSH 宿主,需上游修复。
51
+ */
52
+ export const TRANSPORT_API_CLIENT_SHIM = `<script data-dsh-pocket-transport-shim="1">!function(){try{var K='__DSH_TRANSPORT__',cur=globalThis[K];function patch(t){try{if(t&&typeof t==='object'&&typeof t.createApiClient!=='function'){try{Object.defineProperty(t,'createApiClient',{value:function(){return null;},writable:true,configurable:true});}catch(e){try{t.createApiClient=function(){return null;};}catch(e2){}}}}catch(e){}return t;}if(cur)patch(cur);Object.defineProperty(globalThis,K,{configurable:true,enumerable:true,get:function(){return cur;},set:function(t){cur=patch(t);}});}catch(e){}}();</script>`;
53
+
54
+ const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
55
+
56
+ /**
57
+ * DSH Desktop(桌面版)渲染进程兼容补丁(issue #3/#4,已于 issue #76 停用)。
58
+ *
59
+ * 历史:旧版 dsh-plugin-desktop 的 client 在页面加载时从 URL query 读
60
+ * `dsh-desktop-mode` 与 `dsh-desktop-platform`,缺失即抛
61
+ * "invalid or missing dsh-desktop-mode null" → 页面崩(手机扫码访问桌面版时正是如此)。
62
+ * 本脚本用 history.replaceState 补上这两个参数(无跳转、不重载),取最轻的
63
+ * `compatibility` 模式——不激活桌面布局,避免与移动端适配叠加。
64
+ *
65
+ * @deprecated 不要再注入(issue #76,DSH Desktop 2.0.3 起):
66
+ * ① mode 与 platform **同时缺失**时,parseDesktopClientEnvironment 直接返回 undefined
67
+ * (视作非桌面外壳,跳过全部桌面逻辑),正是手机/浏览器页面需要的效果;
68
+ * ② 只要 URL 上出现任一 dsh-desktop-* 标记,客户端就强制校验整组(material +
69
+ * semver version + mica),只补两个必然抛 "invalid or missing
70
+ * dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
71
+ * ③ 更糟:decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
72
+ * 普通浏览器判为 denied(403),刷新后直接打不开。
73
+ * lib/index.js 已不再注入本脚本;保留导出仅为兼容旧版本桌面端与既有测试。
74
+ */
75
+ export function desktopEnvPatchScript(platform) {
76
+ const p = ['darwin', 'win32', 'linux'].includes(platform) ? platform : 'linux';
77
+ return `<script data-dsh-pocket-desktop-patch="1">!function(){try{var s=new URLSearchParams(location.search);if(!s.has('dsh-desktop-mode')||!s.has('dsh-desktop-platform')){s.set('dsh-desktop-mode','compatibility');s.set('dsh-desktop-platform','${p}');var u=new URL(location.href);u.search=s.toString();history.replaceState(null,'',u);}}catch(e){}}();</script>`;
78
+ }
79
+
80
+ /** 上游响应是否压缩过(压缩流不能做文本注入,会损坏页面)。 */
81
+ function isCompressed(headers) {
82
+ return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
83
+ }
84
+
85
+ /**
86
+ * 默认注入到经代理的 HTML 文档里:crypto.randomUUID / AbortSignal.any polyfill
87
+ * (非安全上下文必需)+ issue #96 的 transport.createApiClient 兜底。
88
+ */
89
+ export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL + TRANSPORT_API_CLIENT_SHIM;
90
+
91
+ /**
92
+ * DSH Desktop advanced 模式不支持的提示覆盖层(issue #19)。
93
+ * advanced 组合会禁用网页版 ui-layout,而桌面 layout 只在 advanced client 提供——
94
+ * 手机页面被注入 compatibility 后无任何 layout 服务 → 启动白屏(Failed to load plugins)。
95
+ * 该脚本在页面上叠加一个固定警告层,让用户明确知道原因(而不是无解白屏)。
96
+ */
97
+ export function advancedNoticeScript() {
98
+ return `<script data-dsh-pocket-advanced-notice="1">!function(){try{var d=document.createElement('div');d.style.cssText='position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);color:#fff;font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;padding:24px';d.textContent='DSH 桌面端处于 advanced 模式,手机访问暂不支持。请在桌面端设置中切回 compatibility 模式后重启。| DSH Desktop is in advanced mode — phone access is not supported yet. Switch back to compatibility in the desktop app and restart.';document.documentElement.appendChild(d);}catch(e){}}();</script>`;
99
+ }
100
+
101
+ // ---------- 可选访问令牌认证(issue #13 + #33) ----------
102
+ // 只对受保护 Host(公网隧道 + 局域网按开关)强制。
103
+ // 登录成功后种 HttpOnly 持久 cookie(Max-Age 30 天)→ SPA 内部 API/WS 自动携带。
104
+ // 会话保持(issue #33):cookie 值 = sha256(PIN:sessionKey)——sessionKey 是 dsh web
105
+ // 进程级随机密钥(lib/index.js 每次启动生成)。于是:
106
+ // - 电脑 dsh web 一直开着 → 手机输一次密码后长期免输(持久 cookie)
107
+ // - dsh web 重启/更新 → sessionKey 变化 → 旧 cookie 失效 → 手机重新输入
108
+ const TOKEN_COOKIE = 'dsh_pocket_token';
109
+ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 天(秒)
110
+
111
+ /** cookie 校验值:有 sessionKey 时派生,无则退化为 PIN 本身(向后兼容)。 */
112
+ function cookieFor(token, sessionKey) {
113
+ if (!sessionKey) return token;
114
+ return createHash('sha256').update(`${token}:${sessionKey}`).digest('hex');
115
+ }
116
+
117
+ // ---------- 登录速率限制(issue #40,改进版方案 A) ----------
118
+ // 8 位数字密码(10^8 组合)本身可接受,真正风险是「无限制重试」让穷举可行。
119
+ // 这里做三层防护(内存态,随进程生命周期,与 sessionKey 一致):
120
+ // 1) 单 IP 滑动窗口:60 秒内失败 ≥5 次 → 锁 60 秒(429)
121
+ // 2) 全局滑动窗口:1 分钟全局失败 > 50 次 → 全局锁 30 秒(防分布式扫描换 IP 绕过)
122
+ // 3) 成功登录清空该 IP 计数
123
+ // IP 识别:优先 cf-connecting-ip(Cloudflare 在隧道入口设置的**真实**客户端 IP,
124
+ // 可信);无则回退 socket remoteAddress。**不信任客户端 x-forwarded-for**(可伪造)。
125
+ export const DEFAULT_RATE_LIMIT = {
126
+ windowMs: 60_000, // 失败计数滑动窗口
127
+ maxFailures: 5, // 窗口内失败阈值 → 触发单 IP 锁
128
+ lockMs: 60_000, // 单 IP 锁定时长
129
+ globalMaxFailures: 50, // 全局失败阈值(同窗口)→ 触发全局锁
130
+ globalLockMs: 30_000, // 全局锁定时长
131
+ };
132
+ function createRateLimiter(cfg = {}) {
133
+ const c = { ...DEFAULT_RATE_LIMIT, ...cfg };
134
+ const failCounts = new Map(); // ip -> { count, windowStart }
135
+ const ipLocks = new Map(); // ip -> lockedUntil
136
+ const global = { count: 0, windowStart: 0, lockedUntil: 0 };
137
+ return {
138
+ /** 该 IP 当前是否被锁;返回 { locked, retryAfter }。 */
139
+ status(ip) {
140
+ const now = Date.now();
141
+ if (global.lockedUntil > now) return { locked: true, retryAfter: Math.ceil((global.lockedUntil - now) / 1000) };
142
+ const until = ipLocks.get(ip) ?? 0;
143
+ if (until > now) return { locked: true, retryAfter: Math.ceil((until - now) / 1000) };
144
+ return { locked: false, retryAfter: 0 };
145
+ },
146
+ /** 记一次失败:维护滑动窗口计数,达阈值触发单 IP / 全局锁。 */
147
+ record(ip) {
148
+ const now = Date.now();
149
+ let rec = failCounts.get(ip);
150
+ if (!rec || now - rec.windowStart > c.windowMs) rec = { count: 0, windowStart: now };
151
+ rec.count++;
152
+ failCounts.set(ip, rec);
153
+ if (now - global.windowStart > c.windowMs) { global.count = 0; global.windowStart = now; }
154
+ global.count++;
155
+ if (rec.count >= c.maxFailures) ipLocks.set(ip, now + c.lockMs);
156
+ if (global.count >= c.globalMaxFailures) global.lockedUntil = now + c.globalLockMs;
157
+ // 防内存膨胀:超过 2000 条记录时清掉已过窗口期的条目
158
+ if (failCounts.size > 2000) {
159
+ for (const [k, v] of failCounts) {
160
+ if (now - v.windowStart > c.windowMs) failCounts.delete(k);
161
+ }
162
+ }
163
+ },
164
+ /** 成功登录:清空该 IP 计数与锁。 */
165
+ clear(ip) {
166
+ failCounts.delete(ip);
167
+ ipLocks.delete(ip);
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * 客户端真实 IP(限速与握手计数的身份键):
173
+ * - 来自本机(cloudflared 隧道回连,源地址 loopback)→ 认 cf-connecting-ip(Cloudflare 边缘写的真实客户端 IP);
174
+ * - 其余一律用 socket 源地址。
175
+ *
176
+ * 为什么不能无条件信任 cf-connecting-ip:那是**请求头**,能直连代理端口的人可以随手
177
+ * 伪造。隧道流量必经本机 cloudflared(源地址必为 loopback),所以把它限定在 loopback
178
+ * 来源既不影响隧道场景,又让「换头即换身份」的限速绕过失效。XFF 同理,始终不认。
179
+ */
180
+ export function clientIp(req) {
181
+ const addr = String(req.socket?.remoteAddress ?? '');
182
+ if (classifySource(addr) === 'loopback') {
183
+ const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
184
+ if (cf) return cf;
185
+ }
186
+ return addr || 'unknown';
187
+ }
188
+
189
+ function parseCookies(header) {
190
+ const out = {};
191
+ for (const part of String(header ?? '').split(';')) {
192
+ const eq = part.indexOf('=');
193
+ if (eq > 0) out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
199
+ function loginPageHtml(error, isPublic, retryAfter = 0) {
200
+ const where = isPublic ? '此公网地址' : '此局域网地址';
201
+ const whereEn = isPublic ? 'This public address' : 'This LAN address';
202
+ const errMsg = error === 'locked'
203
+ ? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
204
+ : error ? '密码错误,请重试 | Wrong PIN, try again' : '';
205
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
206
+ <meta name="viewport" content="width=device-width,initial-scale=1">
207
+ <title>DSH Pocket · 访问验证</title>
208
+ <style>
209
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
210
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:320px;width:calc(100% - 48px);text-align:center}
211
+ h1{font-size:16px;margin:0 0 4px;color:#111827}
212
+ p{font-size:13px;color:#6b7280;margin:0 0 16px}
213
+ input{width:100%;box-sizing:border-box;padding:10px 12px;font-size:18px;letter-spacing:6px;text-align:center;border:1px solid #d1d5db;border-radius:8px;outline:none;margin-bottom:12px}
214
+ input:focus{border-color:#4f6ef7}
215
+ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer}
216
+ .err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}
217
+ </style></head><body><div class="card">
218
+ <h1>🔐 DSH Pocket</h1>
219
+ <p>${where}受访问密码保护,请输入 8 位密码(英文字母或数字) | ${whereEn} is password-protected — enter the 8-character PIN (letters/digits)</p>
220
+ <div class="err">${errMsg}</div>
221
+ <form method="post" action="/pocket-login">
222
+ <input name="token" type="password" maxlength="8" autocomplete="one-time-code" autofocus required>
223
+ <button type="submit">进入 | Enter</button>
224
+ </form>
225
+ </div></body></html>`;
226
+ }
227
+
228
+ /**
229
+ * Host 信任边界分类(issue #66:fail closed)。
230
+ *
231
+ * 旧逻辑只认 `*.trycloudflare.com` 后缀为公网,其余一律当局域网——用户自建
232
+ * 命名隧道/反向代理指向本机端口时,固定域名被误判成局域网;若局域网密码又
233
+ * 关着,公网入口就无密码裸奔。现在反转为 fail closed:
234
+ * - loopback:localhost / 127.x / ::1 / 0.0.0.0(本机与 cloudflared 回连)
235
+ * - lan:RFC1918 私网 IPv4、CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,
236
+ * 公网不可路由)、IPv6 ULA/link-local、`.local`(mDNS)、无点单标签名(NetBIOS 计算机名等)
237
+ * - public:其余一切 Host(trycloudflare 或任何陌生域名)→ 强制公网密码
238
+ *
239
+ * @returns {'loopback'|'lan'|'public'}
240
+ */
241
+ export function classifyHost(host) {
242
+ let name = String(host ?? '').trim().toLowerCase();
243
+ if (name.startsWith('[')) {
244
+ const end = name.indexOf(']');
245
+ if (end >= 0) name = name.slice(1, end); // [::1]:3081 → ::1
246
+ } else {
247
+ name = name.replace(/:\d+$/, ''); // hostname:3081 / 127.0.0.1:3081 → 去掉端口
248
+ }
249
+ if (name === 'localhost' || name === '0.0.0.0' || name === '::1' || /^127\./.test(name)) return 'loopback';
250
+ // RFC1918 私网 + CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,公网不可路由)
251
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/.test(name)) return 'lan';
252
+ if (/^(?:fe80:|f[cd][0-9a-f]{2}:)/.test(name) && name.includes(':')) return 'lan'; // IPv6 link-local / ULA
253
+ if (name === '' || name.includes(':')) return 'loopback'; // 裸 IPv6 / 无 Host → 当本机
254
+ if (name.endsWith('.local') || !name.includes('.')) return 'lan'; // mDNS / NetBIOS 单标签名
255
+ return 'public';
256
+ }
257
+
258
+ /** 该 Host 是否受访问密码保护(公网一律要密码;局域网/本机按设置页开关)。 */
259
+ function isProtectedHost(host, isProtected) {
260
+ return isProtected ? isProtected(host) : classifyHost(host) === 'public';
261
+ }
262
+
263
+ /** 保护强度序:本机最弱(可免密)→ 局域网(按开关)→ 公网(永远要密码)。 */
264
+ const HOST_CLASS_RANK = { loopback: 0, lan: 1, public: 2 };
265
+
266
+ /**
267
+ * 非本机来源(局域网 / 公网 / relay)**唯一**允许调用的 pocket 端点。
268
+ *
269
+ * `/dsh-pocket` 是设置页通道,会返回公网/局域网 PIN(pocket.status)、能改开关、
270
+ * 能管设备。它本来是给「电脑上的设置页」用的,而电脑走 127.0.0.1 —— 也就是
271
+ * loopback。手机虽然有同一个界面,但没有任何理由调用这条通道。
272
+ *
273
+ * 移动端确实需要它,但只需要一个端点:复制文件内容(issue #17,走 RPC 让主机读文件)。
274
+ * 所以这里用**白名单**而不是黑名单:以后往 /dsh-pocket 加新端点时,默认是
275
+ * 「远程不可达」,不会因为忘记登记而悄悄多开一个口子。
276
+ *
277
+ * 背景:DSH 宿主的信任栅栏只看 Host/Origin,而本代理会把远程请求的 Host 改写成
278
+ * loopback 权威(那是它存在的意义),所以栅栏在远程场景下必然放行 —— 这条
279
+ * 边界只能由代理自己守。
280
+ */
281
+ export const REMOTE_ALLOWED_POCKET_ENDPOINTS = new Set(['pocket.fileRead']);
282
+
283
+ /**
284
+ * 该请求是否命中设置页通道(`/dsh-pocket/<endpoint>`)且端点不在白名单内。
285
+ * 路径形如 `/dsh-pocket/pocket.status`(endpoint 就在 path 里,无需解析 body)。
286
+ *
287
+ * @param {string} reqUrl
288
+ * @param {Set<string>} allowed
289
+ */
290
+ export function isPocketChannelPath(reqUrl, allowed) {
291
+ let pathname;
292
+ try {
293
+ pathname = new URL(reqUrl ?? '/', 'http://dsh.invalid').pathname;
294
+ } catch {
295
+ pathname = String(reqUrl ?? '/').split('?')[0];
296
+ }
297
+ if (pathname !== '/dsh-pocket' && !pathname.startsWith('/dsh-pocket/')) return false;
298
+ const endpoint = pathname.slice('/dsh-pocket'.length).replace(/^\/+/, '');
299
+ if (endpoint === '') return true; // 裸通道路径也一律拒(没有合法用途)
300
+ return !allowed.has(endpoint);
301
+ }
302
+
303
+ /**
304
+ * 按 TCP 源地址给出来源类别(issue #90)。
305
+ * 与 classifyHost 的区别在**兜底方向**:Host 头里认不出的形态按 loopback 处理
306
+ * (历史行为,避免裸 IPv6 之类把本机访问判成公网);而源地址认不出时必须按
307
+ * public 处理——源地址是我们唯一不可伪造的信息,兜底方向错了整条防线就白搭。
308
+ * @returns {'loopback'|'lan'|'public'|null} null 表示拿不到源地址(不做任何收紧)
309
+ */
310
+ export function classifySource(addr) {
311
+ let a = String(addr ?? '').trim().toLowerCase();
312
+ if (!a) return null;
313
+ if (a.startsWith('::ffff:')) a = a.slice(7); // IPv4-mapped IPv6(Node 双栈监听时常见)
314
+ if (a === '::1' || /^127\./.test(a)) return 'loopback';
315
+ // RFC1918 私网 + CGNAT 100.64/10(与 classifyHost 保持同一套网段判定)
316
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/.test(a)) return 'lan';
317
+ if (/^169\.254\./.test(a)) return 'lan'; // IPv4 link-local
318
+ if (/^(?:fe80:|f[cd][0-9a-f]{2}:)/.test(a)) return 'lan'; // IPv6 link-local / ULA
319
+ return 'public';
320
+ }
321
+
322
+ /**
323
+ * 用于策略判定的 Host(issue #90 第 7 条)。
324
+ *
325
+ * Host 头完全由客户端控制:能直连代理端口的人只要写 `Host: 127.0.0.1:3081`
326
+ * 就会被 classifyHost 判成本机,从而绕过局域网开关和局域网密码——用户若按
327
+ * README 关掉了局域网密码,这就是一条零认证通道。TCP 源地址无法伪造,用它给
328
+ * Host 声明设一个下限。
329
+ *
330
+ * **只收紧、绝不放松**:经 cloudflared 隧道进来的公网请求源地址正是 127.0.0.1,
331
+ * 若按源地址覆盖就会把公网访问降级成本机免密,比原来的问题更严重。所以仅当
332
+ * 声明的保护级别**低于**来源真实级别时,才改用源地址参与判定。
333
+ */
334
+ export function policyHost(req, host) {
335
+ const actual = classifySource(req?.socket?.remoteAddress);
336
+ if (!actual) return host;
337
+ const claimed = classifyHost(host);
338
+ if (HOST_CLASS_RANK[actual] <= HOST_CLASS_RANK[claimed]) return host;
339
+ // 用真实源地址替代被伪造的 Host 参与后续全部策略判定(密码归属、局域网开关、
340
+ // 局域网地址覆盖),保证各处判定看到的是同一个来源。
341
+ let addr = String(req.socket.remoteAddress);
342
+ if (addr.toLowerCase().startsWith('::ffff:')) addr = addr.slice(7);
343
+ return addr;
344
+ }
345
+
346
+ /**
347
+ * 该 Host 是否 loopback(本机 / cloudflared 回环)。
348
+ * 「关闭局域网」只拦截经局域网 IP/主机名访问的请求,loopback 与公网(trycloudflare)放行:
349
+ * - cloudflared 隧道以 `http://127.0.0.1:<port>` 回连本机代理,必须放行;
350
+ * - 电脑自己访问 127.0.0.1/localhost 也应放行(仅当用户手动浏览本代理时)。
351
+ */
352
+ function isLoopbackHost(host) {
353
+ let name = String(host ?? '').trim().toLowerCase();
354
+ if (name.startsWith('[')) {
355
+ const end = name.indexOf(']');
356
+ if (end >= 0) name = name.slice(1, end); // [::1]:port → ::1
357
+ } else {
358
+ name = name.replace(/:\d+$/, ''); // hostname:port / 127.0.0.1:port → 去掉端口
359
+ }
360
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1' || name === '0.0.0.0';
361
+ }
362
+
363
+ /** 局域网访问已关闭时的提示页(浏览器导航时显示;API/WS 返回 403 JSON/拒绝握手)。 */
364
+ function lanDisabledPageHtml() {
365
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
366
+ <meta name="viewport" content="width=device-width,initial-scale=1">
367
+ <title>DSH Pocket · 局域网访问已关闭</title>
368
+ <style>
369
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
370
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:360px;width:calc(100% - 48px);text-align:center}
371
+ h1{font-size:16px;margin:0 0 8px;color:#111827}
372
+ p{font-size:13px;color:#6b7280;margin:0;line-height:1.6}
373
+ </style></head><body><div class="card">
374
+ <h1>🔒 DSH Pocket</h1>
375
+ <p>局域网访问已关闭,扫码/链接均不可用。<br>请在电脑上重新开启后再试。<br><br>LAN access is disabled — the QR code and link are unavailable.<br>Re-enable it on the computer to continue.</p>
376
+ </div></body></html>`;
377
+ }
378
+
379
+ /** 桌面端浏览器访问门禁提示页(issue #81):DSH Desktop 未开启「浏览器访问」时,
380
+ * 上游 desktop-browser-access 门禁对普通浏览器(含经本代理转发的手机)返回 403
381
+ * `forbidden`,且本代理无法携带 Electron renderer secret 绕过。对符合该特征的
382
+ * 浏览器导航请求返回此可操作提示页;API/WS 与其余 403 原样透传。 */
383
+ function desktopAccessBlockedPageHtml() {
384
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
385
+ <meta name="viewport" content="width=device-width,initial-scale=1">
386
+ <title>DSH Pocket · 桌面端未开启浏览器访问</title>
387
+ <style>
388
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
389
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:392px;width:calc(100% - 48px);text-align:center}
390
+ h1{font-size:16px;margin:0 0 8px;color:#111827}
391
+ p{font-size:13px;color:#6b7280;margin:0 0 12px;line-height:1.6}
392
+ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#374151}
393
+ .step{text-align:left;background:#f9fafb;border:1px solid #eef2f7;border-radius:10px;padding:12px 14px;margin-top:8px;font-size:12px;color:#4b5563;line-height:1.8}
394
+ </style></head><body><div class="card">
395
+ <h1>🖥️ DSH Pocket</h1>
396
+ <p>你已通过访问密码,但页面仍被拦截。<br>因为本机 DSH Desktop 未开启「浏览器访问」,桌面门禁拒绝了普通浏览器(含手机)的页面请求。</p>
397
+ <div class="step">
398
+ <strong>解决方法(任选其一):</strong><br>
399
+ 1. DSH Desktop → 设置 → 窗口 / 模式 → 开启「浏览器访问」(自动切到 compatibility 模式)→ <strong>重启 DSH Desktop</strong>。<br>
400
+ 2. 或在配置文件中设置:<br>
401
+ <code>dsh-desktop: { mode: compatibility, openBrowser: true }</code><br>
402
+ 然后重启 DSH Desktop,再刷新本页。
403
+ </div>
404
+ <p style="margin-top:14px">注意:访问密码登录成功 ≠ 已获得桌面 Web 访问授权。门禁由 DSH Desktop 控制,pocket 无法代为绕过。</p>
405
+ <p style="color:#9ca3af">The host DSH Desktop has "browser access" disabled. Enable it (Settings → window/mode → browser access → restart), or set <code>dsh-desktop.mode: compatibility, openBrowser: true</code>, then refresh.</p>
406
+ </div></body></html>`;
407
+ }
408
+
409
+ /** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
410
+ function isHtmlRequest(req) {
411
+ const accept = String(req.headers.accept ?? '');
412
+ if (accept.includes('text/html')) return true;
413
+ const url = String(req.url ?? '');
414
+ // 按 pathname 判断,别用 `url === '/'` 严格比 —— 根路径常带 query
415
+ // (`/?dsh-pocket-auth=1`、`/?dsh-pocket-retry=1`、`/?token=…`),
416
+ // 那些同样是浏览器导航,漏判会让它们拿到 401/303 而不是该给的页面。
417
+ let pathname = url;
418
+ try { pathname = new URL(url || '/', 'http://dsh.invalid').pathname; } catch { /* 用原值兜底 */ }
419
+ return pathname === '/' || /\.html?$/i.test(pathname);
420
+ }
421
+
422
+ /**
423
+ * 常量时间的密码比较(issue #90):普通 `===` 会在首个不同字节处提前返回,
424
+ * 理论上可被计时侧信道逐字节还原 PIN。长度不同直接判否(PIN 长度固定,
425
+ * 长度本身不是秘密),等长则走 timingSafeEqual。
426
+ */
427
+ function safeEqual(a, b) {
428
+ const ba = Buffer.from(String(a ?? ''), 'utf8');
429
+ const bb = Buffer.from(String(b ?? ''), 'utf8');
430
+ if (ba.length !== bb.length) return false;
431
+ return timingSafeEqual(ba, bb);
432
+ }
433
+
434
+ /**
435
+ * 请求是否携带 `?token=` —— 用于区分「一次密码尝试」与「普通未认证访问」(issue #90)。
436
+ * 只有前者计入限速失败:普通未认证访问(无 cookie 无 token)本来就该看到登录页,
437
+ * 若也计数,正常用户第一次打开页面就会把自己锁死(子资源还会放大)。
438
+ * cookie 不匹配同样不计数——cookie 值是 sha256(PIN:sessionKey),攻击者不知道
439
+ * 进程级 sessionKey,这条通道本身不可穷举;而 dsh web 重启后旧 cookie 必然失配,
440
+ * 计数只会误锁老用户。
441
+ */
442
+ function hasQueryToken(req) {
443
+ try {
444
+ return new URL(req.url ?? '/', 'http://x').searchParams.get('token') != null;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+
450
+ /** 校验请求是否已认证。返回 { ok, rawQueryToken }:
451
+ * - ok=true:已认证;rawQueryToken 是 URL `?token=<PIN>` 命中的那条原始密码(用于种 cookie);
452
+ * - 已有 cookie 命中 → rawQueryToken=null(不要重复种)。 */
453
+ function authCheck(req, tokens, sessionKey) {
454
+ const list = (Array.isArray(tokens) ? tokens : tokens ? [tokens] : []).filter(Boolean);
455
+ if (list.length === 0) return { ok: true, rawQueryToken: null };
456
+ const cookies = parseCookies(req.headers.cookie);
457
+ const cookieTok = cookies[TOKEN_COOKIE];
458
+ if (cookieTok) {
459
+ for (const token of list) {
460
+ if (safeEqual(cookieTok, cookieFor(token, sessionKey))) return { ok: true, rawQueryToken: null };
461
+ }
462
+ }
463
+ const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
464
+ if (qTok) {
465
+ // URL `?token=<原始 PIN>` 用于分享/扫码直达:用户拿到 URL 就能直输明文 PIN,
466
+ // 不需要把 sha256 哈希也告诉他们。匹配上后由调用方通过 maybeSeedAuthCookie
467
+ // 种 HttpOnly 哈希 cookie,让浏览器后续子资源(assets/*.js 等)也走 cookie
468
+ // 路径——避免「主页 200 但子资源 401」白屏(issue #35)。
469
+ for (const token of list) {
470
+ if (safeEqual(qTok, token)) return { ok: true, rawQueryToken: qTok };
471
+ }
472
+ }
473
+ return { ok: false, rawQueryToken: null };
474
+ }
475
+
476
+ /** 当请求通过 `?token=<原始 PIN>` 进入且尚无 cookie 时,在响应里种 HttpOnly cookie。
477
+ * monkey-patch res.writeHead 把 set-cookie 头注入到第一个响应里——同 POST /pocket-login
478
+ * 路径(lib/proxy.mjs 显式 res.writeHead 加 set-cookie)效果一致。 */
479
+ function maybeSeedAuthCookie(req, res, rawToken, sessionKey) {
480
+ if (!rawToken || !sessionKey) return;
481
+ if (parseCookies(req.headers.cookie)[TOKEN_COOKIE]) return; // 已有 cookie 就不重复种
482
+ const expected = cookieFor(rawToken, sessionKey);
483
+ if (!expected) return;
484
+ const origWriteHead = res.writeHead.bind(res);
485
+ res.writeHead = function (statusCode, headers) {
486
+ const h = { ...(headers ?? {}) };
487
+ const cookie = `${TOKEN_COOKIE}=${expected}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`;
488
+ // 已有 set-cookie 头(数组/字符串都支持)则追加
489
+ const prev = h['set-cookie'];
490
+ if (Array.isArray(prev)) h['set-cookie'] = [...prev, cookie];
491
+ else if (typeof prev === 'string') h['set-cookie'] = [prev, cookie];
492
+ else h['set-cookie'] = cookie;
493
+ return origWriteHead(statusCode, h);
494
+ };
495
+ }
496
+
497
+ /**
498
+ * 把浏览器可见的权威改写成 loopback 权威。
499
+ * 除 Host/Origin 外,还必须规范化 Referer 与 Sec-Fetch-Site:DSH 宿主的特权方法
500
+ * 栅栏(settings.describe/credentials.* 等 PRIVILEGED_METHODS)会拒绝
501
+ * sec-fetch-site === 'cross-site',并校验 Origin 与 Host 匹配;远程访问的这两个头
502
+ * 若不改写,设置/凭据平面会在宿主侧被 403(issue #58 的另一半)。
503
+ * 统一小写化键名,避免 Node 原样转发时大小写键并存导致重复头。
504
+ */
505
+ function loopbackAuthority(headers, upstream) {
506
+ const authority = `${upstream.host}:${upstream.port}`;
507
+ const out = {};
508
+ for (const [k, v] of Object.entries(headers)) {
509
+ const lk = k.toLowerCase();
510
+ if (lk === 'host' || lk === 'origin' || lk === 'referer' || lk === 'sec-fetch-site') continue;
511
+ out[lk] = v;
512
+ }
513
+ out.host = authority;
514
+ out.origin = `http://${authority}`;
515
+ const referer = headers.referer ?? headers.Referer;
516
+ if (referer) {
517
+ try {
518
+ const ref = new URL(referer);
519
+ ref.protocol = 'http:';
520
+ ref.host = authority;
521
+ out.referer = ref.toString();
522
+ } catch {
523
+ out.referer = `http://${authority}/`;
524
+ }
525
+ }
526
+ out['sec-fetch-site'] = 'same-origin';
527
+ return out;
528
+ }
529
+
530
+ // ---------- dsh web 浏览器会话 token(issue #77) ----------
531
+ // 新版 dsh web(>= 0.1.2-alpha.1)给浏览器会话加了启动 token:根路径 `GET /` 必须带一次
532
+ // `?token=<启动 token>` 换一个绑定 authority 的 cookie,之后 /api 与 WebSocket 才放行;
533
+ // 否则一律 401("dsh web authentication required")。手机扫码进来的 URL 天然没有这个
534
+ // token,所以代理要在转发时补一次。
535
+ //
536
+ // 只在 `GET /` 且请求还没带 dsh-auth-* cookie 时注入:上游拿到 token 会 303 回干净的根
537
+ // 路径,若每次都注入就会 303 循环(浏览器很快报"重定向次数过多")。
538
+ const DSH_AUTH_COOKIE = 'dsh-auth-';
539
+ /**
540
+ * 去掉 URL 上所有 `dsh-desktop-*` query 参数(issue #75)。
541
+ *
542
+ * dsh-pocket 在 ≤ 2.1.1 会用 `history.replaceState` 往页面 URL 上写
543
+ * `dsh-desktop-mode=compatibility` 和 `dsh-desktop-platform=<系统>`
544
+ * (desktopEnvPatchScript,已在 2.1.2 删除)。副作用是:用户当时收藏/保存过
545
+ * 的那个地址**一直带着这两个参数**。升级之后我们不再注入了,但用户打开旧
546
+ * 收藏时 URL 里仍然有 —— 上游 `decideDesktopBrowserAccess` 只要见到
547
+ * `dsh-desktop-` 前缀就认定是渲染器请求,普通浏览器没有渲染器 token,直接
548
+ * 403 forbidden。表现就是「我已经升到最新版了,还是 forbidden」。
549
+ *
550
+ * 脏参数是我们写进去的,就得由我们清掉。所有方法、所有路径都清理(不只是
551
+ * `GET /`)——API 与 WebSocket 握手带上这些参数同样会被拦。
552
+ *
553
+ * @param {string} reqUrl - 原始请求路径(含 query)。
554
+ * @returns {string} 清理后的路径;无该前缀参数或解析失败时原样返回。
555
+ */
556
+ export function stripDesktopMarkers(reqUrl) {
557
+ let u;
558
+ try {
559
+ u = new URL(reqUrl ?? '/', 'http://dsh.invalid');
560
+ } catch {
561
+ return reqUrl;
562
+ }
563
+ const doomed = [...u.searchParams.keys()].filter((k) => k.startsWith('dsh-desktop-'));
564
+ if (doomed.length === 0) return reqUrl;
565
+ for (const key of doomed) u.searchParams.delete(key);
566
+ return `${u.pathname}${u.search}`;
567
+ }
568
+
569
+ export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launchToken) {
570
+ if (method !== 'GET') return reqUrl;
571
+ let u;
572
+ try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
573
+ if (u.pathname !== '/') return reqUrl;
574
+ // 登录成功后跳回的 `/?dsh-pocket-auth=1`:强制重做一次握手(旧 cookie 可能已过期/被撤销)
575
+ const force = u.searchParams.has('dsh-pocket-auth');
576
+ if (!force && String(cookieHeader ?? '').includes(DSH_AUTH_COOKIE)) return reqUrl;
577
+ if (!launchToken) return reqUrl;
578
+ u.searchParams.set('token', launchToken);
579
+ return `${u.pathname}${u.search}`;
580
+ }
581
+
582
+ // ---------- 会话握手重试计数(issue #91) ----------
583
+ // Safari(iOS/macOS)不持久化「http:// + 纯 IP 源」上由 3xx 响应下发的 cookie,
584
+ // 于是 dsh web 的 launch-token→cookie 握手永远收敛不了:代理每次 `GET /`
585
+ // 都补 `?token=`,上游每次 303 回 `/`,浏览器每次都不带 cookie → 无限重定向
586
+ // (Safari 报「发生了太多重定位」)。
587
+ //
588
+ // 两道防线:
589
+ // 1) 代理把这次 303 改写成 200 过渡页(Set-Cookie 照发 + meta refresh 跳回 `/`),
590
+ // 200 响应上的 cookie 不会被 Safari 的重定向 cookie 策略丢掉;
591
+ // 2) 万一 1) 也不管用,用下面的计数器在若干次尝试后停止注入 token 并给出
592
+ // 可操作提示页——宁可给用户一句人话,也不要无限转圈。
593
+ //
594
+ // 只按客户端 IP 计数(无需 cookie 支持,正适合「cookie 用不了」的这个场景)。
595
+ export const DEFAULT_HANDSHAKE_LIMIT = 3;
596
+ export const HANDSHAKE_WINDOW_MS = 60_000;
597
+ /** 提示页「重试」按钮用的查询参数:命中即清空该 IP 的失败计数,且不往上游透传。 */
598
+ export const HANDSHAKE_RETRY_PARAM = 'dsh-pocket-retry';
599
+
600
+ /** 摘掉某个查询参数后重新拼路径;解析失败或本来就没有则原样返回。 */
601
+ export function stripQueryParam(reqUrl, name) {
602
+ let u;
603
+ try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
604
+ if (!u.searchParams.has(name)) return reqUrl;
605
+ u.searchParams.delete(name);
606
+ return `${u.pathname}${u.search}`;
607
+ }
608
+
609
+ export function createHandshakeTracker({ max = DEFAULT_HANDSHAKE_LIMIT, windowMs = HANDSHAKE_WINDOW_MS } = {}) {
610
+ /** ip -> { count, start } */
611
+ const hits = new Map();
612
+ return {
613
+ /** 记一次握手注入,返回窗口内的累计次数。 */
614
+ record(ip, now = Date.now()) {
615
+ const rec = hits.get(ip);
616
+ if (!rec || now - rec.start > windowMs) {
617
+ hits.set(ip, { count: 1, start: now });
618
+ return 1;
619
+ }
620
+ rec.count += 1;
621
+ return rec.count;
622
+ },
623
+ /** 握手成功(拿到会话 cookie 的请求)→ 清零。 */
624
+ clear(ip) {
625
+ hits.delete(ip);
626
+ },
627
+ /** 该 IP 是否已达重试上限。 */
628
+ exhausted(ip) {
629
+ const rec = hits.get(ip);
630
+ return !!rec && rec.count >= max;
631
+ },
632
+ /** 清理过期条目,防长期运行内存膨胀。 */
633
+ prune(now = Date.now()) {
634
+ for (const [ip, rec] of hits) {
635
+ if (now - rec.start > windowMs) hits.delete(ip);
636
+ }
637
+ },
638
+ };
639
+ }
640
+
641
+ /** 握手过渡页:200 + Set-Cookie(由调用方带上)+ meta refresh 跳回干净根路径。 */
642
+ export function handshakePageHtml() {
643
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
644
+ <meta name="viewport" content="width=device-width,initial-scale=1">
645
+ <meta http-equiv="refresh" content="0; url=/">
646
+ <title>DSH Pocket · 正在进入 | opening…</title>
647
+ <style>
648
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
649
+ p{font-size:13px;color:#6b7280;margin:0}
650
+ </style></head><body><p>正在进入… | opening…</p></body></html>`;
651
+ }
652
+
653
+ /** 握手反复失败时的提示页(issue #91):说清原因并给出可操作的规避办法。 */
654
+ export function handshakeBlockedPageHtml() {
655
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
656
+ <meta name="viewport" content="width=device-width,initial-scale=1">
657
+ <title>DSH Pocket · 无法完成登录握手</title>
658
+ <style>
659
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
660
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px 22px;max-width:380px;width:calc(100% - 40px)}
661
+ h1{font-size:15px;margin:0 0 10px;color:#111827}
662
+ p{font-size:13px;color:#6b7280;margin:0 0 10px;line-height:1.7}
663
+ code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px}
664
+ a{color:#4f6ef7}
665
+ </style></head><body><div class="card">
666
+ <h1>🔁 无法完成登录握手</h1>
667
+ <p>浏览器没有保存 DSH 下发的会话 cookie,代理反复重试后仍未成功,因此停在这里而不是无限跳转。</p>
668
+ <p><strong>Safari(iOS/macOS)</strong> 在 <code>http://</code> 纯 IP 地址上不会保存这类 cookie,局域网入口因此进不去。</p>
669
+ <p>可以试试:<br>
670
+ ① 换 Chromium 系浏览器(Chrome / Edge)打开局域网地址;<br>
671
+ ② 改用<strong>公网入口</strong>(设置页开启公网访问,拿到 <code>https://…trycloudflare.com</code> 地址)——HTTPS 域名上 Safari 正常。</p>
672
+ <p style="margin-top:14px"><a href="/?${HANDSHAKE_RETRY_PARAM}=1" style="display:inline-block;padding:8px 14px;background:#4f6ef7;color:#fff;border-radius:8px;text-decoration:none;font-size:13px">重试一次 | Retry</a></p>
673
+ <p style="color:#9ca3af;font-size:12px">Browser did not keep the session cookie, so the login handshake could not complete (issue #91). Safari over plain <code>http://</code> + IP is the known case — try Chrome, or use the public HTTPS entry.</p>
674
+ </div></body></html>`;
675
+ }
676
+
677
+ // ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
678
+ // DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
679
+ // 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
680
+ // 丢弃:没有 FIN/RST,浏览器收不到 close 事件,dsh-client-connection 也就
681
+ // 永远不会重连——手机页面看起来还开着,实则实时通道已死(消息不同步、
682
+ // 点击会话卡在加载)。
683
+ //
684
+ // 代理在每个透传的 WS 连接上定期向浏览器侧发送协议层 Ping(0x89 0x00,
685
+ // server→client 不掩码):
686
+ // - 浏览器网络栈按 RFC 6455 自动回 Pong(不经过任何 JS),一来一回让
687
+ // 双向都有流量,NAT/防火墙空闲超时不再触发;
688
+ // - 连续 missLimit 个周期没有任何入站字节(浏览器已死或链路被静默丢弃)
689
+ // → 主动 destroy 连接:浏览器拿到 close 后 dsh-client-connection 会
690
+ // 按指数退避自动重连,实时通道随即恢复。
691
+ // 只 Ping 浏览器侧:上游是本机 loopback,不会过期;浏览器回的 Pong 原样
692
+ // 透传给上游 ws 服务(未请求的 Pong 对 ws 库无害,只触发无害的 pong 事件)。
693
+ const WS_PING_FRAME = Buffer.from([0x89, 0x00]); // FIN + opcode 9、长度 0、不掩码
694
+
695
+ /**
696
+ * 在透传的浏览器侧 socket 上挂载心跳:定期 Ping 保活 + 静默断链检测。
697
+ * 任一路由方向只要有字节流动(Pong 响应)就把静默计数归零;连续 missLimit
698
+ * 个周期零入站流量则判定链路已死,销毁 socket 触发浏览器端重连。
699
+ * @param {import('node:net').Socket} socket 浏览器侧的透传 socket
700
+ * @param {{intervalMs?:number, missLimit?:number}} [opts] 心跳周期与容忍的静默周期数
701
+ */
702
+ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 } = {}) {
703
+ let misses = 0;
704
+ let stopped = false;
705
+ const onInbound = () => { misses = 0; };
706
+ const timer = setInterval(() => {
707
+ if (stopped) return;
708
+ misses += 1;
709
+ if (misses >= missLimit) {
710
+ // 连续多个周期没有任何入站流量(连 Pong 都没有)→ 静默断链,断开让客户端重连
711
+ socket.destroy();
712
+ return;
713
+ }
714
+ // write 到已销毁的 socket 会抛错(destroy 竞态),写前检查并兜底
715
+ if (!socket.destroyed) {
716
+ try { socket.write(WS_PING_FRAME); } catch { /* 忽略 */ }
717
+ }
718
+ }, intervalMs);
719
+ timer.unref?.();
720
+ socket.on('data', onInbound);
721
+ const cleanup = () => {
722
+ if (stopped) return;
723
+ stopped = true;
724
+ clearInterval(timer);
725
+ socket.off('data', onInbound);
726
+ socket.off('close', cleanup);
727
+ socket.off('error', cleanup);
728
+ };
729
+ socket.on('close', cleanup);
730
+ socket.on('error', cleanup);
731
+ }
732
+
733
+ /**
734
+ * 启动 dsh-pocket 代理。
735
+ * @param {object} opts
736
+ * @param {number} [opts.port] 监听端口(默认 3081;dsh web 保持 3080)
737
+ * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
738
+ * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
739
+ * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
740
+ * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, getAltTokens?, isProtected, sessionKey }
741
+ * - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类)
742
+ * - getAltTokens?(host) → 替代令牌列表(可选),校验时与主 PIN 任一命中即放行
743
+ * @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
744
+ * @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
745
+ * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
746
+ * @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
747
+ */
748
+ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '', handshakeLimit, deviceAuth = null } = {}) {
749
+ const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
750
+ // 会话握手重试计数(issue #91):Safari 在 http://IP 源上丢 3xx 的 cookie → 死循环
751
+ const handshake = createHandshakeTracker(
752
+ typeof handshakeLimit === 'number' ? { max: handshakeLimit } : {},
753
+ );
754
+ const server = createServer((req, res) => {
755
+ // 处理函数是 async(设备认证要读 body / 算 scrypt),而 createServer 不消费
756
+ // 返回的 promise —— 必须自己兜住 rejection,否则一个异常就是一个
757
+ // unhandledRejection,在 Node 默认策略下会直接干掉 dsh web 进程。
758
+ void handleRequest(req, res).catch((err) => {
759
+ log?.(`dsh-pocket: request failed | 请求处理失败: ${err?.message ?? err}`);
760
+ try {
761
+ if (!res.headersSent) {
762
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
763
+ res.end('dsh-pocket: internal error');
764
+ } else {
765
+ res.destroy();
766
+ }
767
+ } catch { /* 连接已断 */ }
768
+ });
769
+ });
770
+
771
+ async function handleRequest(req, res) {
772
+ // 策略判定一律用 policyHost(issue #90):Host 头可伪造,用不可伪造的 TCP 源地址
773
+ // 给它设下限。转发给上游的 Host 由 loopbackAuthority 单独改写,不受这里影响。
774
+ const host = policyHost(req, String(req.headers.host ?? ''));
775
+ // 注入内容可以按 host 定制(设备通道要额外注入「真实用户活动」上报脚本)
776
+ const inject = typeof injectHtml === 'function' ? injectHtml(host) : injectHtml;
777
+ const isPublic = classifyHost(host) === 'public';
778
+ // 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
779
+ // 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
780
+ // 公网流量随后照常走访问密码认证。
781
+ if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
782
+ if (isHtmlRequest(req)) {
783
+ res.writeHead(403, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
784
+ res.end(lanDisabledPageHtml());
785
+ } else {
786
+ res.writeHead(403, { 'content-type': 'application/json', 'cache-control': 'no-store' });
787
+ res.end('{"error":"lan-disabled"}');
788
+ }
789
+ return;
790
+ }
791
+ // 设备认证通道(自建 relay):整条通道先过设备闸门。
792
+ //
793
+ // 闸门必须覆盖该 host 上的**所有**请求,而不只是首页:如果只拦 `/`,
794
+ // 拿到真实域名的攻击者绕开首页直接打 `/api/...` 或 WebSocket 就进去了。
795
+ // 认证通过则整段跳过下面的共享 PIN 校验(设备认证是它的替代,不是叠加)。
796
+ let deviceChannel = false;
797
+ if (deviceAuth && deviceAuth.handles(host)) {
798
+ deviceChannel = true;
799
+ const verdict = await deviceAuth.gate(req, res);
800
+ if (verdict !== 'continue') return;
801
+ }
802
+
803
+ // 设置页通道(/dsh-pocket)只对**本机**开放,白名单端点除外。
804
+ // 放在设备闸门之后:设备通道未认证时上面已经拦掉了,走到这里的是
805
+ // 「已通过设备认证的手机」或「局域网/公网的手机」——它们同样不该拿到
806
+ // pocket.status(会泄露公网 PIN)或设备管理端点。
807
+ if (classifyHost(host) !== 'loopback' && isPocketChannelPath(req.url, REMOTE_ALLOWED_POCKET_ENDPOINTS)) {
808
+ res.writeHead(403, { 'content-type': 'application/json', 'cache-control': 'no-store' });
809
+ res.end('{"error":"pocket-rpc-local-only"}');
810
+ return;
811
+ }
812
+
813
+ // 访问令牌认证(issue #13 + #18 + #33 + #40 + #69):局域网与公网按开关/来源要求密码
814
+ if (auth && !deviceChannel) {
815
+ const protectedHost = isProtectedHost(host, auth.isProtected);
816
+ const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
817
+ // 临时 PIN(issue #69):按 host 同源分发(公网临时 PIN 只在公网入口放行,局域网同理)
818
+ const altTokens = protectedHost && token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
819
+ const sessionKey = auth.sessionKey ?? null;
820
+ const acceptedTokens = token ? [token, ...altTokens] : [];
821
+ if (protectedHost && token) {
822
+ const ip = clientIp(req);
823
+ // 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
824
+ if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
825
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
826
+ if (rl.locked) {
827
+ res.writeHead(429, {
828
+ 'content-type': 'text/html; charset=utf-8',
829
+ 'cache-control': 'no-store',
830
+ 'retry-after': String(rl.retryAfter),
831
+ });
832
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
833
+ return;
834
+ }
835
+ let body = '';
836
+ req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
837
+ req.on('end', () => {
838
+ const submitted = String(new URLSearchParams(body).get('token') ?? '');
839
+ // 与 cookie / ?token= 两条通道保持一致:常量时间比较 + 主 PIN 与替代令牌
840
+ // (临时 PIN,issue #69 钩子)任一命中即放行。原先这里是 `submitted === token`
841
+ // ——既会提前返回(计时侧信道可逐字节还原 PIN),又把替代令牌挡在门外。
842
+ const matched = acceptedTokens.find((candidate) => safeEqual(submitted, candidate));
843
+ if (matched !== undefined) {
844
+ limiter?.clear(ip);
845
+ res.writeHead(302, {
846
+ // 带上 dsh-pocket-auth=1:登录成功后强制重做一次浏览器会话握手,
847
+ // 换掉可能已过期/被撤销的 dsh web 会话 cookie(issue #77)
848
+ location: '/?dsh-pocket-auth=1',
849
+ 'set-cookie': `${TOKEN_COOKIE}=${cookieFor(matched, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
850
+ 'cache-control': 'no-store',
851
+ });
852
+ res.end();
853
+ } else {
854
+ limiter?.record(ip);
855
+ log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
856
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
857
+ res.end(loginPageHtml(true, isPublic, 0));
858
+ }
859
+ });
860
+ return;
861
+ }
862
+ // `?token=<PIN>` 是与 POST 登录等价的一次密码尝试(issue #90):此前只有 POST
863
+ // 分支调用 limiter.record(),这条通道既不计数也不受锁定约束,等于给攻击者留了
864
+ // 一个可全速穷举 8 位 PIN 的旁路。下面把它并入同一套限速。
865
+ const isGuess = hasQueryToken(req);
866
+ if (isGuess) {
867
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
868
+ // 锁定期内直接拒绝、不做比对——否则锁定窗口本身就是免费的穷举窗口。
869
+ // 正确密码也一并拒绝,与 POST 登录的锁定语义保持一致。
870
+ if (rl.locked) {
871
+ if (isHtmlRequest(req)) {
872
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
873
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
874
+ } else {
875
+ res.writeHead(429, {
876
+ 'content-type': 'application/json',
877
+ 'cache-control': 'no-store',
878
+ 'retry-after': String(rl.retryAfter),
879
+ });
880
+ res.end('{"error":"too-many-attempts"}');
881
+ }
882
+ return;
883
+ }
884
+ }
885
+ const authResult = authCheck(req, acceptedTokens, sessionKey);
886
+ if (!authResult.ok) {
887
+ if (isGuess) {
888
+ limiter?.record(ip);
889
+ log?.(`dsh-pocket: bad ?token= from ${ip} | URL 密码错误 IP: ${ip}`);
890
+ }
891
+ if (isHtmlRequest(req)) {
892
+ // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
893
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
894
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
895
+ res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
896
+ } else {
897
+ res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
898
+ res.end('{"error":"unauthorized"}');
899
+ }
900
+ return;
901
+ }
902
+ // ?token=<原始 PIN> 直达时种 HttpOnly cookie,让浏览器后续子资源也走 cookie 路径(issue #35)
903
+ if (authResult.rawQueryToken) {
904
+ limiter?.clear(ip); // 与 POST 登录成功一致:正确的分享链接不该逐步累积到锁定
905
+ maybeSeedAuthCookie(req, res, authResult.rawQueryToken, sessionKey);
906
+ }
907
+ }
908
+ }
909
+ const headers = loopbackAuthority({ ...req.headers }, upstream);
910
+ // dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
911
+ const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
912
+ // 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
913
+ // 握手重试上限(issue #91):Safari 不保存 http://IP 源上 3xx 下发的 cookie →
914
+ // 补 token → 上游 303 → 浏览器仍无 cookie → 无限循环。达到上限就别再补了,
915
+ // 让请求落到提示页,而不是继续转圈。
916
+ const handshakeIp = clientIp(req);
917
+ // `/?dsh-pocket-retry=1`:提示页上的「重试」入口——清掉这一轮的失败计数,让握手
918
+ // 重新走一遍(否则用户得干等窗口过期)。这参数是我们自己加的,不往上游透传。
919
+ let cleanPath = stripDesktopMarkers(req.url);
920
+ if (cleanPath.includes(HANDSHAKE_RETRY_PARAM)) {
921
+ handshake.clear(handshakeIp);
922
+ cleanPath = stripQueryParam(cleanPath, HANDSHAKE_RETRY_PARAM);
923
+ }
924
+ const handshakeOver = launchTok !== '' && handshake.exhausted(handshakeIp);
925
+ const upstreamPath = handshakeOver
926
+ ? cleanPath
927
+ : upstreamPathWithLaunchToken(cleanPath, req.method, req.headers.cookie, launchTok);
928
+ const didInjectToken = upstreamPath !== cleanPath;
929
+ if (didInjectToken) {
930
+ handshake.record(handshakeIp);
931
+ handshake.prune();
932
+ }
933
+ // 请求带上了会话 cookie → 这一轮的握手计数可以清掉了(说明 cookie 通路是好的)
934
+ if (!didInjectToken && String(req.headers.cookie ?? '').includes(DSH_AUTH_COOKIE)) {
935
+ handshake.clear(handshakeIp);
936
+ }
937
+ if (handshakeOver && isHtmlRequest(req)) {
938
+ // 已判定握不上手 → 停在这里给人话,别再转圈。API/WS 不走这里(上游会 401)。
939
+ res.writeHead(503, {
940
+ 'content-type': 'text/html; charset=utf-8',
941
+ 'cache-control': 'no-store',
942
+ 'x-dsh-pocket-handshake': 'blocked',
943
+ });
944
+ res.end(handshakeBlockedPageHtml());
945
+ return;
946
+ }
947
+ const proxyReq = httpRequest(
948
+ { host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
949
+ (proxyRes) => {
950
+ log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
951
+ const contentType = String(proxyRes.headers['content-type'] ?? '');
952
+ // issue #91:我们刚注入了 launch token,上游回 303(换 cookie 后回干净根路径)。
953
+ // Safari 不保存 http://IP 源上 3xx 响应下发的 cookie,于是浏览器下次仍无 cookie
954
+ // → 代理再注入 → 再 303 → 死循环。这里把这次 303 改成 200 过渡页:Set-Cookie
955
+ // 照发(200 上的 cookie 不会被那条重定向策略丢掉),页面用 meta refresh 跳回 `/`。
956
+ if (didInjectToken && proxyRes.statusCode === 303 && isHtmlRequest(req)) {
957
+ const out = { ...proxyRes.headers };
958
+ delete out['content-length'];
959
+ delete out['transfer-encoding'];
960
+ delete out.location; // 自己跳,不留给浏览器去重做一次 303
961
+ const page = Buffer.from(handshakePageHtml(), 'utf8');
962
+ out['content-type'] = 'text/html; charset=utf-8';
963
+ out['content-length'] = String(page.length);
964
+ out['cache-control'] = 'no-store';
965
+ out['x-dsh-pocket-handshake'] = 'transition';
966
+ proxyRes.resume(); // 消费掉上游响应体,释放连接
967
+ res.writeHead(200, out);
968
+ res.end(page);
969
+ return;
970
+ }
971
+ // issue #81:上游 desktop-browser-access 门禁(DSH Desktop 未开启「浏览器访问」时)
972
+ // 对普通浏览器(含经本代理转发的手机)返回 403 text/plain "forbidden",且本代理无法
973
+ // 携带 Electron renderer secret 绕过。对符合该特征的**浏览器导航**请求改写为可操作
974
+ // 提示页;API/WS 与其余 403 原样透传(不猜 secret、不把任意 403 都判为桌面门禁)。
975
+ if (proxyRes.statusCode === 403 && contentType.includes('text/plain')) {
976
+ const navReq = isHtmlRequest(req);
977
+ const gateChunks = [];
978
+ let gateOverflow = false;
979
+ const passRaw403 = () => {
980
+ if (res.headersSent) return;
981
+ res.writeHead(403, { ...proxyRes.headers });
982
+ if (gateChunks.length) res.write(Buffer.concat(gateChunks));
983
+ proxyRes.pipe(res);
984
+ };
985
+ proxyRes.on('data', (c) => {
986
+ if (gateOverflow) return;
987
+ gateChunks.push(c);
988
+ if (Buffer.concat(gateChunks).length > 65536) { gateOverflow = true; gateChunks.length = 0; passRaw403(); }
989
+ });
990
+ proxyRes.on('end', () => {
991
+ if (gateOverflow || res.headersSent) return;
992
+ const body = Buffer.concat(gateChunks).toString('utf8').trim();
993
+ if (navReq && body === 'forbidden') {
994
+ res.writeHead(403, {
995
+ 'content-type': 'text/html; charset=utf-8',
996
+ 'cache-control': 'no-store',
997
+ 'x-dsh-pocket-gate': 'desktop-browser-access',
998
+ });
999
+ res.end(desktopAccessBlockedPageHtml());
1000
+ } else {
1001
+ res.writeHead(403, { ...proxyRes.headers });
1002
+ res.end(Buffer.concat(gateChunks));
1003
+ }
1004
+ });
1005
+ proxyRes.on('error', () => res.destroy());
1006
+ return;
1007
+ }
1008
+ // 只给**未压缩**的 HTML 文档注入(SSE/WS/JS/CSS 原样透传;压缩流注入会损坏页面);
1009
+ // 注入后修正 Content-Length
1010
+ if (inject && contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
1011
+ const chunks = [];
1012
+ proxyRes.on('data', (c) => chunks.push(c));
1013
+ proxyRes.on('end', () => {
1014
+ let html = Buffer.concat(chunks).toString('utf8');
1015
+ if (!html.includes(INJECT_MARK)) {
1016
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${inject}`);
1017
+ }
1018
+ const out = Buffer.from(html, 'utf8');
1019
+ const outHeaders = { ...proxyRes.headers };
1020
+ delete outHeaders['content-length'];
1021
+ delete outHeaders['transfer-encoding'];
1022
+ outHeaders['content-length'] = String(out.length);
1023
+ // 注入后的 HTML 携带本代理的动态补丁(含注入标记判重),
1024
+ // 必须禁用缓存——否则手机/中间层(nginx 等)拿到没有补丁的旧
1025
+ // 文档后,isLoopback 修复不生效且难以排查(表现为"改了没效果")。
1026
+ outHeaders['cache-control'] = 'no-store';
1027
+ delete outHeaders['etag'];
1028
+ delete outHeaders['last-modified'];
1029
+ delete outHeaders['expires'];
1030
+ res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
1031
+ res.end(out);
1032
+ });
1033
+ proxyRes.on('error', () => res.destroy());
1034
+ return;
1035
+ }
1036
+ // 大 JSON/text 响应**流式压缩**(issue #12):长会话历史一次返回 17MB+,
1037
+ // 局域网直连与隧道段都吃满带宽;压缩到 ~1MB。跳过已压缩、SSE 流
1038
+ // (/api/events.* 原样透传)、HTML(走上面的注入分支)。
1039
+ // brotli 质量选 6(issue #25):zlib 默认 q11 压 17MB 要 40s+,手机直接超时;
1040
+ // q6 实测 128ms(比 gzip 的 88ms 略慢但同档)且输出更小(1.00MB vs 1.20MB)。
1041
+ const acceptEncoding = String(req.headers['accept-encoding'] ?? '');
1042
+ const canGzip = /\bgzip\b/.test(acceptEncoding);
1043
+ const canBr = /\bbr\b/.test(acceptEncoding);
1044
+ const isEventStream = contentType.includes('text/event-stream');
1045
+ const knownLen = Number(proxyRes.headers['content-length'] || 0);
1046
+ const shouldCompress = (canGzip || canBr)
1047
+ && !isCompressed(proxyRes.headers)
1048
+ && !isEventStream
1049
+ && (contentType.includes('application/json') || contentType.startsWith('text/'))
1050
+ && (knownLen === 0 || knownLen >= 1024);
1051
+ if (shouldCompress) {
1052
+ const enc = canBr ? 'br' : 'gzip';
1053
+ const outHeaders = { ...proxyRes.headers };
1054
+ delete outHeaders['content-length'];
1055
+ delete outHeaders['transfer-encoding'];
1056
+ outHeaders['content-encoding'] = enc;
1057
+ res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
1058
+ const z = enc === 'br'
1059
+ ? createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 6 } })
1060
+ : createGzip();
1061
+ proxyRes.pipe(z).pipe(res);
1062
+ // 任一端断开都要清理(含压缩流)。注意:不能用 proxyRes 的 'close'
1063
+ // 来掐 res——正常结束后 close 也会触发,此时压缩流可能还没写完,
1064
+ // 会误杀连接;异常中止用 'aborted'。
1065
+ res.on('close', () => { proxyRes.destroy(); z.destroy(); });
1066
+ proxyRes.on('error', () => { z.destroy(); res.destroy(); });
1067
+ proxyRes.on('aborted', () => { z.destroy(); res.destroy(); });
1068
+ z.on('error', () => res.destroy());
1069
+ return;
1070
+ }
1071
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
1072
+ proxyRes.pipe(res);
1073
+ // 任一端断开都要清理另一端:客户端断连销毁上游流(不留僵尸),
1074
+ // 上游流中途断开也要掐断客户端(否则响应头已发、体没发完 → 悬挂)
1075
+ res.on('close', () => proxyRes.destroy());
1076
+ proxyRes.on('error', () => res.destroy());
1077
+ proxyRes.on('close', () => { if (!res.writableEnded) res.destroy(); });
1078
+ },
1079
+ );
1080
+ proxyReq.on('error', (err) => {
1081
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
1082
+ res.end(`dsh-pocket: 无法连接上游 dsh web(${upstream.host}:${upstream.port})——先启动 dsh web | ${err.message}`);
1083
+ });
1084
+ req.pipe(proxyReq);
1085
+ }
1086
+
1087
+ // WebSocket upgrade(DSH 的 /api/events.mux + events.host 流式通道)原样透传
1088
+ server.on('upgrade', (req, socket, head) => {
1089
+ // 与 HTTP 侧同一套判定(issue #90):否则伪造 Host 的 WS 握手仍可绕过局域网开关
1090
+ const host = policyHost(req, String(req.headers.host ?? ''));
1091
+ const isPublic = classifyHost(host) === 'public';
1092
+ // 局域网访问关闭:拦截经局域网 Host 的 WS 握手(公网/loopback 放行——issue #66 fail closed)
1093
+ if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
1094
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
1095
+ socket.destroy();
1096
+ return;
1097
+ }
1098
+ // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入;含临时 PIN,issue #69)
1099
+ if (deviceAuth && deviceAuth.handles(host)) {
1100
+ // 设备通道:WS 握手也必须带有效设备会话。漏了这一步,未认证的人就能
1101
+ // 直接开一条 WS 拿到实时事件流——等于认证形同虚设。
1102
+ if (!deviceAuth.authorizeUpgrade(req)) {
1103
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
1104
+ socket.destroy();
1105
+ return;
1106
+ }
1107
+ } else if (auth) {
1108
+ const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
1109
+ const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
1110
+ const acceptedTokens = token ? [token, ...altTokens] : [];
1111
+ // WS 握手上的 ?token= 与 HTTP 侧同权(issue #90):不并入限速的话,攻击者
1112
+ // 只要把穷举换到 upgrade 请求上就照样不受限。
1113
+ const wsIp = clientIp(req);
1114
+ const wsGuess = hasQueryToken(req);
1115
+ if (token && wsGuess) {
1116
+ const rl = limiter?.status(wsIp) ?? { locked: false, retryAfter: 0 };
1117
+ if (rl.locked) {
1118
+ socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${rl.retryAfter}\r\nConnection: close\r\n\r\n`);
1119
+ socket.destroy();
1120
+ return;
1121
+ }
1122
+ }
1123
+ const wsAuth = authCheck(req, acceptedTokens, auth.sessionKey ?? null);
1124
+ if (token && !wsAuth.ok) {
1125
+ if (wsGuess) {
1126
+ limiter?.record(wsIp);
1127
+ log?.(`dsh-pocket: bad ws ?token= from ${wsIp} | WS 密码错误 IP: ${wsIp}`);
1128
+ }
1129
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
1130
+ socket.destroy();
1131
+ return;
1132
+ }
1133
+ if (token && wsAuth.ok && wsGuess) limiter?.clear(wsIp);
1134
+ }
1135
+ const headers = loopbackAuthority({ ...req.headers }, upstream);
1136
+ const proxyReq = httpRequest({
1137
+ // 同样清掉历史遗留的 dsh-desktop-* 参数(issue #75)
1138
+ host: upstream.host, port: upstream.port, method: req.method, path: stripDesktopMarkers(req.url), headers, agent: false,
1139
+ });
1140
+ proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
1141
+ socket.write('HTTP/1.1 101 Switching Protocols\r\n');
1142
+ // 原样回传上游的 upgrade 头(Sec-WebSocket-Accept 等)
1143
+ const raw = [];
1144
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
1145
+ raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
1146
+ }
1147
+ socket.write(`${raw.join('\r\n')}\r\n\r\n`);
1148
+ if (proxyHead?.length) socket.write(proxyHead);
1149
+ // pipe 必须 end:false:默认 end:true 会在对端 FIN 时抢先 end() 对端 socket
1150
+ // (优雅 FIN),此时 teardown 的 destroy() 已无法强制关闭对方——上游只收
1151
+ // 到 FIN 进入 half-open 永不关闭(PR #56)。半关闭统一交给下面的 'end'
1152
+ // 监听 → teardown destroy(RST 强制关闭双方)。
1153
+ socket.pipe(proxySocket, { end: false });
1154
+ proxySocket.pipe(socket, { end: false });
1155
+ // 心跳注入(PR #41):保活 + 静默断链检测(见 attachWebSocketHeartbeat)
1156
+ if (heartbeat !== false) attachWebSocketHeartbeat(socket, heartbeat ?? {});
1157
+ // 任一端断开都要清理另一端(避免上游残留僵尸连接占用 dsh 连接槽)。
1158
+ // 上游侧必须 resetAndDestroy(发 RST):destroy() 只发干净 FIN,而上游
1159
+ // http server 默认 allowHalfOpen=true,收到 FIN 不自动关闭 → 上游仍悬挂
1160
+ // (PR #56 实测)。RST 强制对端立即关闭。
1161
+ const teardown = () => {
1162
+ try { proxySocket.resetAndDestroy?.() ?? proxySocket.destroy(); } catch { try { proxySocket.destroy(); } catch {} }
1163
+ try { socket.destroy(); } catch {}
1164
+ };
1165
+ // 上游侧透传 socket 的读错误(如 dsh web 重启/断开时的 ECONNRESET)必须
1166
+ // 吞掉并清理对端,否则未处理的 'error' 事件会让整个 dsh web 进程崩溃退出。
1167
+ proxySocket.on('error', () => { try { socket.destroy(); } catch {} });
1168
+ proxySocket.on('close', teardown);
1169
+ socket.on('close', teardown);
1170
+ // 半关闭(收到对端 FIN 的 'end')对双向转发同样意味着这一端要走了:http server
1171
+ // 默认 allowHalfOpen=true,收到 FIN 只触发 'end' 不自动关——若不在 'end' 时销毁,
1172
+ // 浏览器/App 直接关页(不发 WS close 帧就 FIN)留下的连接会永久挂在 half-open
1173
+ // 状态,上游连接槽被占(且 server.close() 永远等不完)。双向流里半关闭无意义。
1174
+ socket.on('end', teardown);
1175
+ proxySocket.on('end', teardown);
1176
+ });
1177
+ // 上游返回普通 HTTP 响应(非 101):把状态码/头回写后断开,别让客户端永久挂起
1178
+ proxyReq.on('response', (proxyRes) => {
1179
+ if (proxyRes.statusCode === 101) return; // 理论上 101 走 upgrade 事件
1180
+ try {
1181
+ const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
1182
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
1183
+ raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
1184
+ }
1185
+ // end 会 flush 响应头再 FIN——不要紧跟 destroy(),否则排队的头会被丢弃
1186
+ socket.end(raw.join('\r\n') + '\r\n\r\n');
1187
+ proxyRes.resume(); // 消费掉上游响应体,释放连接
1188
+ } catch { socket.destroy(); }
1189
+ });
1190
+ proxyReq.on('error', () => socket.destroy());
1191
+ // 关键:浏览器在握手请求后可能立即发出首帧(如 mux 流的初始 RPC),
1192
+ // node 把它放在 upgrade 事件的 head 里。必须先于 end() 写入 proxyReq,
1193
+ // 让上游在 upgrade 事件里就拿到它(与直连行为一致);等 101 之后再写
1194
+ // 会变成迟到的 socket 数据,DSH 的 mux 协议可能错过这个窗口。
1195
+ if (head?.length) proxyReq.write(head);
1196
+ proxyReq.end();
1197
+ socket.on('error', () => socket.destroy());
1198
+ });
1199
+
1200
+ // 跟踪所有 TCP 连接(含 WebSocket upgrade 后的 socket——Node 的
1201
+ // closeAllConnections 不包含它们,不手动销毁 close() 会永远等)
1202
+ const clientSockets = new Set();
1203
+ server.on('connection', (sock) => {
1204
+ clientSockets.add(sock);
1205
+ sock.on('close', () => clientSockets.delete(sock));
1206
+ sock.on('error', () => {}); // 防未处理 error 崩进程
1207
+ });
1208
+
1209
+ return new Promise((resolve, reject) => {
1210
+ server.once('error', reject);
1211
+ server.listen(port, host, () => {
1212
+ const actualPort = server.address().port;
1213
+ resolve({
1214
+ server,
1215
+ port: actualPort,
1216
+ close: () => new Promise((r) => {
1217
+ for (const s of clientSockets) { try { s.destroy(); } catch { /* 忽略 */ } }
1218
+ server.close(() => r());
1219
+ }),
1220
+ });
1221
+ });
1222
+ });
1223
+ }