dsh-remote-web 0.7.2 → 0.7.3-beta.2
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/index.js +140 -3
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/runtime/dsh-setup.mjs +225 -6
|
@@ -438,6 +438,8 @@
|
|
|
438
438
|
明文过境(见 :39 的策略表),那是安全红线。 */
|
|
439
439
|
var seStaleSessionHook = null;
|
|
440
440
|
var seLastStaleCode = "";
|
|
441
|
+
/** 「每页最多一次受控 reload」的闸门键(sessionStorage:同一标签页刷新后仍在 → 最多一次)。 */
|
|
442
|
+
var SE_RELOAD_GUARD_KEY = "dsh-e2ee-dead-reload";
|
|
441
443
|
/** 清掉本机保存的 E2EE 交接单(sessionStorage 一次性 + localStorage 持久)。node 测试无 storage → 静默跳过。 */
|
|
442
444
|
function seForgetHandoverStores() {
|
|
443
445
|
try { if (typeof sessionStorage !== "undefined" && sessionStorage && sessionStorage.removeItem) sessionStorage.removeItem(DSH_E2EE_HANDOVER_KEY); } catch (e) { /* ignore */ }
|
|
@@ -447,34 +449,91 @@
|
|
|
447
449
|
function seE2eeErrorOf(res) {
|
|
448
450
|
try { return String((res && res.headers && res.headers.get && res.headers.get("x-dsh-e2ee-error")) || ""); } catch (e) { return ""; }
|
|
449
451
|
}
|
|
450
|
-
/**
|
|
451
|
-
|
|
452
|
+
/**
|
|
453
|
+
* 收到带 x-dsh-e2ee-error 的响应 → 清失效会话 + 上报(同一个码只报一次,避免刷屏)。
|
|
454
|
+
*
|
|
455
|
+
* 🔴 C3(2026-10-20 诊断,03 报告 §2.4 D1 的真缺陷):旧实现**只清 storage**,
|
|
456
|
+
* 本页内存里的 `_ctx.sess` 一直是那张死票 —— 全文只有 `_install()` 一处给它赋值,
|
|
457
|
+
* 之后再没有任何地方置空。于是这个页面的**后续每一个** HTTP/WS 仍然继续带死 sessId:
|
|
458
|
+
* · WS 被桥端 `1008 e2ee_session_locked` 拒 → 官方客户端安静重连 → 再拒…… 无限循环;
|
|
459
|
+
* · 该页每个受保护请求全 502,但页面"看起来在、其实全死"。
|
|
460
|
+
* 实测规模:dev-08b73ba9de67 单设备一天 nginx 009 8997 次、同时 4064 个 HTTP 200。
|
|
461
|
+
* 所以清 storage 只解决"**下次**进页面",清 `ctx.sess` 才解决"**这一页**别再送死票"。
|
|
462
|
+
* @param {Response} res
|
|
463
|
+
* @param {{sess: *}|null} [ctx] 本页上下文(有就给,给了就同时置空内存会话)
|
|
464
|
+
* @returns {string} 桥端错误码(没有则 "")
|
|
465
|
+
*/
|
|
466
|
+
function seNoteE2eeError(res, ctx) {
|
|
452
467
|
var code = seE2eeErrorOf(res);
|
|
453
468
|
if (!code) return "";
|
|
469
|
+
var dropped = false;
|
|
470
|
+
try { if (ctx && ctx.sess) { ctx.sess = null; dropped = true; } } catch (e) { /* ignore */ }
|
|
454
471
|
seForgetHandoverStores();
|
|
455
472
|
if (code !== seLastStaleCode) {
|
|
456
473
|
seLastStaleCode = code;
|
|
457
|
-
try { if (typeof seStaleSessionHook === "function") seStaleSessionHook(code); } catch (e) { /* ignore */ }
|
|
474
|
+
try { if (typeof seStaleSessionHook === "function") seStaleSessionHook(code, dropped); } catch (e) { /* ignore */ }
|
|
458
475
|
seNotifyFail("加密会话已失效(" + code + "):本机保存的会话已不可用,需要重新解锁。");
|
|
459
476
|
}
|
|
460
477
|
return code;
|
|
461
478
|
}
|
|
462
479
|
|
|
480
|
+
/**
|
|
481
|
+
* 「每页最多一次」受控 reload(C3 第 3 条;防止修好"死循环"却换来"reload 风暴")。
|
|
482
|
+
*
|
|
483
|
+
* 只在这种场合用:桥端**明确**回了 unknown_session(它不认识这张交接单,这张票 100% 是死的),
|
|
484
|
+
* 而本页刚才真的挂着一张死会话(dropped=true)。此时本页所有受保护请求都会 502,
|
|
485
|
+
* 官方客户端也可能已经卡在自己的错误态里 —— 一次刷新能让它从干净的明文态重新起步
|
|
486
|
+
* (没有交接单 → 走"未解锁/明文"分支,功能可用,也不再送死 sessId)。
|
|
487
|
+
* 闸门放 sessionStorage:同一标签页刷新后仍在 → **最多 reload 一次**,不会变成新的 499 来源。
|
|
488
|
+
* node 测试没有 sessionStorage/location → 直接返回 false(纯逻辑不依赖 DOM)。
|
|
489
|
+
* @returns {boolean} 是否真的发起了 reload
|
|
490
|
+
*/
|
|
491
|
+
function seReloadOnceForDeadSession() {
|
|
492
|
+
try {
|
|
493
|
+
if (typeof sessionStorage === "undefined" || !sessionStorage) return false;
|
|
494
|
+
if (sessionStorage.getItem(SE_RELOAD_GUARD_KEY)) return false;
|
|
495
|
+
sessionStorage.setItem(SE_RELOAD_GUARD_KEY, String(Date.now()));
|
|
496
|
+
} catch (e) { return false; }
|
|
497
|
+
try {
|
|
498
|
+
if (typeof location === "undefined" || !location || typeof location.reload !== "function") return false;
|
|
499
|
+
location.reload();
|
|
500
|
+
return true;
|
|
501
|
+
} catch (e2) { return false; }
|
|
502
|
+
}
|
|
503
|
+
|
|
463
504
|
/* ---- 数据 WS 握手失败上报(2026-09-26:线上 mux 握手 404/009 → 用户只看到"什么都没发生") ----
|
|
464
505
|
mux WS 是镜像页与电脑端之间**唯一**的实时数据通道;它连不上时页面不报错,只是所有数据都停住。
|
|
465
|
-
|
|
466
|
-
|
|
506
|
+
这里只做**可观测 + 死会话自愈**:把"没连上"作为明确原因交给徽标(区分"会话未建立/设备离线/网络中断"),
|
|
507
|
+
连续握手失败 ≥3 次且本页还挂着会话时,判定桥端已不认这张交接单(C3,同 seNoteE2eeError 的处置:
|
|
508
|
+
清交接单 + 清内存 ctx.sess + 徽标「加密会话已失效,需要重新解锁」)。
|
|
509
|
+
仍然**绝不在这里重连** —— 官方客户端自己会重连,我们再加一层只会变成"静默无限重连"。 */
|
|
467
510
|
var seWsFailHook = null;
|
|
468
|
-
var seWsFailCount = 0;
|
|
511
|
+
var seWsFailCount = 0; // 累计失败次数(徽标展示"第 N 次")
|
|
512
|
+
var seWsFailStreak = 0; // **连续**失败次数(握手成功一次即清零) —— 死会话判定只看它
|
|
469
513
|
var seWsFailReported = false;
|
|
470
|
-
|
|
514
|
+
/** 连续握手失败到这个次数 → 判定桥端已不认本页的加密会话(2026-10-20 C3)。 */
|
|
515
|
+
var SE_WS_FAIL_INVALIDATE = 3;
|
|
516
|
+
/**
|
|
517
|
+
* @param {string} label WS 标签(日志/徽标用,不含密钥)
|
|
518
|
+
* @param {string} detail "error" 或关闭码/原因
|
|
519
|
+
* @param {{sess: *}|null} [ctx] 本页上下文;给了才可能清会话(没有会话就只上报)
|
|
520
|
+
*/
|
|
521
|
+
function seNoteWsHandshakeFail(label, detail, ctx) {
|
|
471
522
|
seWsFailCount++;
|
|
472
|
-
|
|
523
|
+
seWsFailStreak++;
|
|
524
|
+
var dropped = false;
|
|
525
|
+
if (ctx && ctx.sess && seWsFailStreak >= SE_WS_FAIL_INVALIDATE) {
|
|
526
|
+
/* 桥端连"握手"都不认(1008 e2ee_session_locked / 升级被拒),而本页还挂着会话:
|
|
527
|
+
这张交接单在桥端已经不存在(bridge 重启 / 24h TTL / CAP=32 挤出),留着只会无限重连。 */
|
|
528
|
+
try { ctx.sess = null; dropped = true; } catch (e) { /* ignore */ }
|
|
529
|
+
seForgetHandoverStores();
|
|
530
|
+
}
|
|
531
|
+
if (seWsFailReported && !dropped) return;
|
|
473
532
|
seWsFailReported = true;
|
|
474
|
-
try { if (typeof seWsFailHook === "function") seWsFailHook(String(label || ""), String(detail || ""), seWsFailCount); } catch (e) { /* ignore */ }
|
|
533
|
+
try { if (typeof seWsFailHook === "function") seWsFailHook(String(label || ""), String(detail || ""), seWsFailCount, dropped); } catch (e) { /* ignore */ }
|
|
475
534
|
}
|
|
476
|
-
/** 握手成功后复位(
|
|
477
|
-
function seNoteWsOpened() { seWsFailReported = false; }
|
|
535
|
+
/** 握手成功后复位(下一次再断时还能再报一次;连续计数清零,累计 count 保留用于展示"重试了几次")。 */
|
|
536
|
+
function seNoteWsOpened() { seWsFailReported = false; seWsFailStreak = 0; }
|
|
478
537
|
|
|
479
538
|
/* ---- HTTP 信封传输包装(与 native e2eeFetchEnveloped 同构;输入/输出为 fetch 语义) ---- */
|
|
480
539
|
var SE_OUTER_STRIP = {
|
|
@@ -558,7 +617,7 @@
|
|
|
558
617
|
} catch (e) { throw e; }
|
|
559
618
|
/* router/桥端明文错误页 → 原样透传(零行为),但**先看一眼**是不是桥端在说"会话我不认识"
|
|
560
619
|
(x-dsh-e2ee-error)——是的话清掉本机失效交接单并给出明确原因(见 seNoteE2eeError)。 */
|
|
561
|
-
if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res); return res; }
|
|
620
|
+
if (!seIsEnvelopeResponse(res)) { seNoteE2eeError(res, ctx); return res; }
|
|
562
621
|
var envResp = null;
|
|
563
622
|
try { envResp = await res.json(); } catch (e) {
|
|
564
623
|
seNotifyFail("响应信封非法");
|
|
@@ -606,12 +665,12 @@
|
|
|
606
665
|
/* 握手上就失败(error 早于任何 open)→ 让用户知道"加密数据通道没建立"。
|
|
607
666
|
这是 2026-09-26 故障里最缺的一条信号:mux 被 404 掐掉时页面**一声不响**,
|
|
608
667
|
用户只看到所有数据都停住。 */
|
|
609
|
-
if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, "error");
|
|
668
|
+
if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, "error", self._meta && self._meta.ctx);
|
|
610
669
|
if (typeof self._onerror === "function") self._onerror(ev || { type: "error", target: self });
|
|
611
670
|
};
|
|
612
671
|
inner.onclose = function (ev) {
|
|
613
672
|
self._state = 3;
|
|
614
|
-
if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, (ev && (ev.code || ev.reason)) || "close");
|
|
673
|
+
if (!self._opened) seNoteWsHandshakeFail(self._meta && self._meta.wsLabel, (ev && (ev.code || ev.reason)) || "close", self._meta && self._meta.ctx);
|
|
615
674
|
if (typeof self._onclose === "function") self._onclose(ev || { type: "close", target: self });
|
|
616
675
|
};
|
|
617
676
|
inner.onmessage = function (ev) { self._enqueueInbound(ev); };
|
|
@@ -740,7 +799,7 @@
|
|
|
740
799
|
if (!should) return new NativeWS(passUrl, protocols);
|
|
741
800
|
var target = seBuildWsTarget(scoped, ctx.sess.sessId, seRandomHex(8), base);
|
|
742
801
|
if (!target) return new NativeWS(passUrl, protocols);
|
|
743
|
-
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel });
|
|
802
|
+
return new SeE2eeWs(NativeWS, target.href, protocols, { sess: ctx.sess, wsLabel: target.wsLabel, ctx: ctx });
|
|
744
803
|
}
|
|
745
804
|
SeWs.OPEN = 1; SeWs.CONNECTING = 0; SeWs.CLOSING = 2; SeWs.CLOSED = 3;
|
|
746
805
|
return SeWs;
|
|
@@ -865,14 +924,35 @@
|
|
|
865
924
|
bad_envelope: "响应信封非法。",
|
|
866
925
|
bad_plain: "响应明文非法。"
|
|
867
926
|
};
|
|
868
|
-
|
|
927
|
+
/**
|
|
928
|
+
* @param {string} code 桥端错误码
|
|
929
|
+
* @param {boolean} [dropped] 刚才是否真的清掉了一张**本页正挂着的**死会话
|
|
930
|
+
*/
|
|
931
|
+
function _reportStaleSession(code, dropped) {
|
|
869
932
|
var hint = _STALE_HINT[String(code || "")] || ("电脑端返回:" + String(code || "") + "。");
|
|
870
933
|
_badge("warn", "加密会话已失效,需要重新解锁", hint + " 已清除本机保存的会话;返回设备列表重新点选设备解锁即可恢复加密。");
|
|
934
|
+
/* C3 第 3 条:只有"刚才真的扔掉了一张本页死会话"时才做**一次**受控刷新。
|
|
935
|
+
为什么只在这个分支刷:HTTP 的 x-dsh-e2ee-error 是桥端**主动回答**"我不认识这个会话"
|
|
936
|
+
(这张票是确定的死票),而 WS 握手失败还可能只是电脑端暂时离线 —— 那种情况清了会话就够了,
|
|
937
|
+
刷新只会打断用户。刷新后页面没有交接单 → 走未解锁明文态,明文可用,也不再送死 sessId
|
|
938
|
+
(每标签页最多一次)。 */
|
|
939
|
+
if (dropped) seReloadOnceForDeadSession();
|
|
871
940
|
}
|
|
872
941
|
/* 加密数据 WS(mux)**握手就没成功**:官方客户端只会安静地重连,页面不报错、数据全停。
|
|
873
942
|
这里把原因分档说清楚(会话/设备/网络),但**不做任何重连**(重连是官方的事,
|
|
874
|
-
我们再叠一层就变成"静默无限重连"——正是这次要修掉的形态)。
|
|
875
|
-
|
|
943
|
+
我们再叠一层就变成"静默无限重连"——正是这次要修掉的形态)。
|
|
944
|
+
@param {boolean} [dropped] 连续失败 ≥3 次且刚才清掉了本页死会话(C3 自愈分支) */
|
|
945
|
+
function _reportWsFail(label, detail, count, dropped) {
|
|
946
|
+
if (dropped) {
|
|
947
|
+
/* 徽标文案按 C3 验收口径固定为「加密会话已失效,需要重新解锁」——
|
|
948
|
+
用户据此只有一个动作:回设备列表重新解锁。 */
|
|
949
|
+
_badge("warn", "加密会话已失效,需要重新解锁",
|
|
950
|
+
"与电脑端的加密数据通道连续 " + (Number(count) || SE_WS_FAIL_INVALIDATE) + " 次握手失败,"
|
|
951
|
+
+ "电脑端已不认这个加密会话(通常是电脑端重启过,或会话太多被挤掉)。"
|
|
952
|
+
+ "已清除本机保存的会话,本页数据改为明文继续(功能可用,但不再加密);"
|
|
953
|
+
+ "返回设备列表重新点选设备解锁即可恢复加密。");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
876
956
|
var n = Number(count) || 1;
|
|
877
957
|
var why = String(detail || "") === "error"
|
|
878
958
|
? "电脑端可能未连接(设备离线)、网络中断,或加密会话已失效。"
|
|
@@ -124,6 +124,23 @@
|
|
|
124
124
|
} catch (e) { return ""; }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** 适配层(mobile-adapter)暴露的只读诊断:首屏自动重试次数 / compat-official 降级 / scrim 计数。
|
|
128
|
+
* 为什么从这里带出去(2026-10-20 C4/C6):服务端只有 nginx/mirror_diag 两层时,
|
|
129
|
+
* 分不清"失败后是自愈了还是永久坏了" —— 这三个计数就是"自愈有没有发生"的第一手证据。
|
|
130
|
+
* 没有适配层(宽屏/未注入/旧版本)时返回 undefined:字段整体缺省,不改变既有 payload 形状。 */
|
|
131
|
+
function uiDiag() {
|
|
132
|
+
try {
|
|
133
|
+
var r = W.__dshMaRetryStats;
|
|
134
|
+
var d = W.__dshMaDiag;
|
|
135
|
+
if (!r && !d) return undefined;
|
|
136
|
+
var out = {};
|
|
137
|
+
if (r) out.retry = { page: Number(r.page) || 0, res: Number(r.res) || 0, kind: clip(r.kind || r.lastKind || "", 40) };
|
|
138
|
+
if (d && d.compatOfficial) out.compat = d.compatOfficial.on ? 1 : 0;
|
|
139
|
+
if (d && d.scrim) out.scrim = { on: Number(d.scrim.activations) || 0, fix: Number(d.scrim.invariantHits) || 0 };
|
|
140
|
+
return out;
|
|
141
|
+
} catch (e) { return undefined; }
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
function flush() {
|
|
128
145
|
timer = null;
|
|
129
146
|
if (sent || (items.length === 0 && bootFailed.length === 0)) return;
|
|
@@ -138,6 +155,8 @@
|
|
|
138
155
|
boot_failed: bootFailed.slice(0, 10),
|
|
139
156
|
at: now()
|
|
140
157
|
};
|
|
158
|
+
var ui = uiDiag();
|
|
159
|
+
if (ui) payload.ui = ui; // 可选字段:老服务端无视即可
|
|
141
160
|
try {
|
|
142
161
|
fetch(ENDPOINT, {
|
|
143
162
|
method: "POST",
|