dsh-pocket 2.10.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/proxy.mjs +161 -8
- package/package.json +1 -1
package/lib/proxy.mjs
CHANGED
|
@@ -296,7 +296,14 @@ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#
|
|
|
296
296
|
/** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
|
|
297
297
|
function isHtmlRequest(req) {
|
|
298
298
|
const accept = String(req.headers.accept ?? '');
|
|
299
|
-
|
|
299
|
+
if (accept.includes('text/html')) return true;
|
|
300
|
+
const url = String(req.url ?? '');
|
|
301
|
+
// 按 pathname 判断,别用 `url === '/'` 严格比 —— 根路径常带 query
|
|
302
|
+
// (`/?dsh-pocket-auth=1`、`/?dsh-pocket-retry=1`、`/?token=…`),
|
|
303
|
+
// 那些同样是浏览器导航,漏判会让它们拿到 401/303 而不是该给的页面。
|
|
304
|
+
let pathname = url;
|
|
305
|
+
try { pathname = new URL(url || '/', 'http://dsh.invalid').pathname; } catch { /* 用原值兜底 */ }
|
|
306
|
+
return pathname === '/' || /\.html?$/i.test(pathname);
|
|
300
307
|
}
|
|
301
308
|
|
|
302
309
|
/** 校验请求是否已认证。返回 { ok, rawQueryToken }:
|
|
@@ -407,6 +414,101 @@ export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launch
|
|
|
407
414
|
return `${u.pathname}${u.search}`;
|
|
408
415
|
}
|
|
409
416
|
|
|
417
|
+
// ---------- 会话握手重试计数(issue #91) ----------
|
|
418
|
+
// Safari(iOS/macOS)不持久化「http:// + 纯 IP 源」上由 3xx 响应下发的 cookie,
|
|
419
|
+
// 于是 dsh web 的 launch-token→cookie 握手永远收敛不了:代理每次 `GET /`
|
|
420
|
+
// 都补 `?token=`,上游每次 303 回 `/`,浏览器每次都不带 cookie → 无限重定向
|
|
421
|
+
// (Safari 报「发生了太多重定位」)。
|
|
422
|
+
//
|
|
423
|
+
// 两道防线:
|
|
424
|
+
// 1) 代理把这次 303 改写成 200 过渡页(Set-Cookie 照发 + meta refresh 跳回 `/`),
|
|
425
|
+
// 200 响应上的 cookie 不会被 Safari 的重定向 cookie 策略丢掉;
|
|
426
|
+
// 2) 万一 1) 也不管用,用下面的计数器在若干次尝试后停止注入 token 并给出
|
|
427
|
+
// 可操作提示页——宁可给用户一句人话,也不要无限转圈。
|
|
428
|
+
//
|
|
429
|
+
// 只按客户端 IP 计数(无需 cookie 支持,正适合「cookie 用不了」的这个场景)。
|
|
430
|
+
export const DEFAULT_HANDSHAKE_LIMIT = 3;
|
|
431
|
+
export const HANDSHAKE_WINDOW_MS = 60_000;
|
|
432
|
+
/** 提示页「重试」按钮用的查询参数:命中即清空该 IP 的失败计数,且不往上游透传。 */
|
|
433
|
+
export const HANDSHAKE_RETRY_PARAM = 'dsh-pocket-retry';
|
|
434
|
+
|
|
435
|
+
/** 摘掉某个查询参数后重新拼路径;解析失败或本来就没有则原样返回。 */
|
|
436
|
+
export function stripQueryParam(reqUrl, name) {
|
|
437
|
+
let u;
|
|
438
|
+
try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
|
|
439
|
+
if (!u.searchParams.has(name)) return reqUrl;
|
|
440
|
+
u.searchParams.delete(name);
|
|
441
|
+
return `${u.pathname}${u.search}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function createHandshakeTracker({ max = DEFAULT_HANDSHAKE_LIMIT, windowMs = HANDSHAKE_WINDOW_MS } = {}) {
|
|
445
|
+
/** ip -> { count, start } */
|
|
446
|
+
const hits = new Map();
|
|
447
|
+
return {
|
|
448
|
+
/** 记一次握手注入,返回窗口内的累计次数。 */
|
|
449
|
+
record(ip, now = Date.now()) {
|
|
450
|
+
const rec = hits.get(ip);
|
|
451
|
+
if (!rec || now - rec.start > windowMs) {
|
|
452
|
+
hits.set(ip, { count: 1, start: now });
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
rec.count += 1;
|
|
456
|
+
return rec.count;
|
|
457
|
+
},
|
|
458
|
+
/** 握手成功(拿到会话 cookie 的请求)→ 清零。 */
|
|
459
|
+
clear(ip) {
|
|
460
|
+
hits.delete(ip);
|
|
461
|
+
},
|
|
462
|
+
/** 该 IP 是否已达重试上限。 */
|
|
463
|
+
exhausted(ip) {
|
|
464
|
+
const rec = hits.get(ip);
|
|
465
|
+
return !!rec && rec.count >= max;
|
|
466
|
+
},
|
|
467
|
+
/** 清理过期条目,防长期运行内存膨胀。 */
|
|
468
|
+
prune(now = Date.now()) {
|
|
469
|
+
for (const [ip, rec] of hits) {
|
|
470
|
+
if (now - rec.start > windowMs) hits.delete(ip);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 握手过渡页:200 + Set-Cookie(由调用方带上)+ meta refresh 跳回干净根路径。 */
|
|
477
|
+
export function handshakePageHtml() {
|
|
478
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
479
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
480
|
+
<meta http-equiv="refresh" content="0; url=/">
|
|
481
|
+
<title>DSH Pocket · 正在进入 | opening…</title>
|
|
482
|
+
<style>
|
|
483
|
+
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}
|
|
484
|
+
p{font-size:13px;color:#6b7280;margin:0}
|
|
485
|
+
</style></head><body><p>正在进入… | opening…</p></body></html>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 握手反复失败时的提示页(issue #91):说清原因并给出可操作的规避办法。 */
|
|
489
|
+
export function handshakeBlockedPageHtml() {
|
|
490
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
491
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
492
|
+
<title>DSH Pocket · 无法完成登录握手</title>
|
|
493
|
+
<style>
|
|
494
|
+
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}
|
|
495
|
+
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px 22px;max-width:380px;width:calc(100% - 40px)}
|
|
496
|
+
h1{font-size:15px;margin:0 0 10px;color:#111827}
|
|
497
|
+
p{font-size:13px;color:#6b7280;margin:0 0 10px;line-height:1.7}
|
|
498
|
+
code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px}
|
|
499
|
+
a{color:#4f6ef7}
|
|
500
|
+
</style></head><body><div class="card">
|
|
501
|
+
<h1>🔁 无法完成登录握手</h1>
|
|
502
|
+
<p>浏览器没有保存 DSH 下发的会话 cookie,代理反复重试后仍未成功,因此停在这里而不是无限跳转。</p>
|
|
503
|
+
<p><strong>Safari(iOS/macOS)</strong> 在 <code>http://</code> 纯 IP 地址上不会保存这类 cookie,局域网入口因此进不去。</p>
|
|
504
|
+
<p>可以试试:<br>
|
|
505
|
+
① 换 Chromium 系浏览器(Chrome / Edge)打开局域网地址;<br>
|
|
506
|
+
② 改用<strong>公网入口</strong>(设置页开启公网访问,拿到 <code>https://…trycloudflare.com</code> 地址)——HTTPS 域名上 Safari 正常。</p>
|
|
507
|
+
<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>
|
|
508
|
+
<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>
|
|
509
|
+
</div></body></html>`;
|
|
510
|
+
}
|
|
511
|
+
|
|
410
512
|
// ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
|
|
411
513
|
// DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
|
|
412
514
|
// 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
|
|
@@ -478,8 +580,12 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
|
|
|
478
580
|
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
|
|
479
581
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
480
582
|
*/
|
|
481
|
-
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 = () => '' } = {}) {
|
|
583
|
+
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 } = {}) {
|
|
482
584
|
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
585
|
+
// 会话握手重试计数(issue #91):Safari 在 http://IP 源上丢 3xx 的 cookie → 死循环
|
|
586
|
+
const handshake = createHandshakeTracker(
|
|
587
|
+
typeof handshakeLimit === 'number' ? { max: handshakeLimit } : {},
|
|
588
|
+
);
|
|
483
589
|
const server = createServer((req, res) => {
|
|
484
590
|
const host = String(req.headers.host ?? '');
|
|
485
591
|
const isPublic = classifyHost(host) === 'public';
|
|
@@ -562,17 +668,64 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
562
668
|
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
|
|
563
669
|
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
|
|
564
670
|
// 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
671
|
+
// 握手重试上限(issue #91):Safari 不保存 http://IP 源上 3xx 下发的 cookie →
|
|
672
|
+
// 补 token → 上游 303 → 浏览器仍无 cookie → 无限循环。达到上限就别再补了,
|
|
673
|
+
// 让请求落到提示页,而不是继续转圈。
|
|
674
|
+
const handshakeIp = clientIp(req);
|
|
675
|
+
// `/?dsh-pocket-retry=1`:提示页上的「重试」入口——清掉这一轮的失败计数,让握手
|
|
676
|
+
// 重新走一遍(否则用户得干等窗口过期)。这参数是我们自己加的,不往上游透传。
|
|
677
|
+
let cleanPath = stripDesktopMarkers(req.url);
|
|
678
|
+
if (cleanPath.includes(HANDSHAKE_RETRY_PARAM)) {
|
|
679
|
+
handshake.clear(handshakeIp);
|
|
680
|
+
cleanPath = stripQueryParam(cleanPath, HANDSHAKE_RETRY_PARAM);
|
|
681
|
+
}
|
|
682
|
+
const handshakeOver = launchTok !== '' && handshake.exhausted(handshakeIp);
|
|
683
|
+
const upstreamPath = handshakeOver
|
|
684
|
+
? cleanPath
|
|
685
|
+
: upstreamPathWithLaunchToken(cleanPath, req.method, req.headers.cookie, launchTok);
|
|
686
|
+
const didInjectToken = upstreamPath !== cleanPath;
|
|
687
|
+
if (didInjectToken) {
|
|
688
|
+
handshake.record(handshakeIp);
|
|
689
|
+
handshake.prune();
|
|
690
|
+
}
|
|
691
|
+
// 请求带上了会话 cookie → 这一轮的握手计数可以清掉了(说明 cookie 通路是好的)
|
|
692
|
+
if (!didInjectToken && String(req.headers.cookie ?? '').includes(DSH_AUTH_COOKIE)) {
|
|
693
|
+
handshake.clear(handshakeIp);
|
|
694
|
+
}
|
|
695
|
+
if (handshakeOver && isHtmlRequest(req)) {
|
|
696
|
+
// 已判定握不上手 → 停在这里给人话,别再转圈。API/WS 不走这里(上游会 401)。
|
|
697
|
+
res.writeHead(503, {
|
|
698
|
+
'content-type': 'text/html; charset=utf-8',
|
|
699
|
+
'cache-control': 'no-store',
|
|
700
|
+
'x-dsh-pocket-handshake': 'blocked',
|
|
701
|
+
});
|
|
702
|
+
res.end(handshakeBlockedPageHtml());
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
571
705
|
const proxyReq = httpRequest(
|
|
572
706
|
{ host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
|
|
573
707
|
(proxyRes) => {
|
|
574
708
|
log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
|
|
575
709
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
710
|
+
// issue #91:我们刚注入了 launch token,上游回 303(换 cookie 后回干净根路径)。
|
|
711
|
+
// Safari 不保存 http://IP 源上 3xx 响应下发的 cookie,于是浏览器下次仍无 cookie
|
|
712
|
+
// → 代理再注入 → 再 303 → 死循环。这里把这次 303 改成 200 过渡页:Set-Cookie
|
|
713
|
+
// 照发(200 上的 cookie 不会被那条重定向策略丢掉),页面用 meta refresh 跳回 `/`。
|
|
714
|
+
if (didInjectToken && proxyRes.statusCode === 303 && isHtmlRequest(req)) {
|
|
715
|
+
const out = { ...proxyRes.headers };
|
|
716
|
+
delete out['content-length'];
|
|
717
|
+
delete out['transfer-encoding'];
|
|
718
|
+
delete out.location; // 自己跳,不留给浏览器去重做一次 303
|
|
719
|
+
const page = Buffer.from(handshakePageHtml(), 'utf8');
|
|
720
|
+
out['content-type'] = 'text/html; charset=utf-8';
|
|
721
|
+
out['content-length'] = String(page.length);
|
|
722
|
+
out['cache-control'] = 'no-store';
|
|
723
|
+
out['x-dsh-pocket-handshake'] = 'transition';
|
|
724
|
+
proxyRes.resume(); // 消费掉上游响应体,释放连接
|
|
725
|
+
res.writeHead(200, out);
|
|
726
|
+
res.end(page);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
576
729
|
// issue #81:上游 desktop-browser-access 门禁(DSH Desktop 未开启「浏览器访问」时)
|
|
577
730
|
// 对普通浏览器(含经本代理转发的手机)返回 403 text/plain "forbidden",且本代理无法
|
|
578
731
|
// 携带 Electron renderer secret 绕过。对符合该特征的**浏览器导航**请求改写为可操作
|
package/package.json
CHANGED