@mrrisega/dsh-remote 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/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
- package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
- package/clients/dsh-remote/test/mobile-adapter-compat.test.mjs +175 -0
- package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
- package/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/dsh-setup.mjs +225 -6
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +140 -3
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
- package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
|
@@ -117,7 +117,19 @@ const DEFAULT_CONFIG = path.join(
|
|
|
117
117
|
const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
|
|
118
118
|
// 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
|
|
119
119
|
const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* 隧道心跳间隔(C5)。
|
|
122
|
+
*
|
|
123
|
+
* 为什么从 15s 调到 20s:生产上"30 秒级断连、75% 的隧道会话活不过 60s"(诊断报告 R2),
|
|
124
|
+
* 15s 的心跳 + "丢一次 pong 就断" 让弱网(丢包 5%)下的正常抖动被当成故障,
|
|
125
|
+
* 每次误断都会重连重建隧道 + 打断在途请求。20s 与中继/nginx 的 3600s read timeout 无关,
|
|
126
|
+
* 只影响"多久发现半开连接":最坏 3 个心跳周期(≈60s)发现真死的连接,仍远小于移动网络
|
|
127
|
+
* 常见的 NAT 空闲超时(通常 2~5 分钟)。
|
|
128
|
+
*/
|
|
129
|
+
export const TUNNEL_HEARTBEAT_DEFAULT_MS = 20_000;
|
|
130
|
+
/** 连续丢多少个 pong 才判定隧道已死(C5:旧实现丢 1 次就 terminate,弱网误判率过高)。 */
|
|
131
|
+
export const HEARTBEAT_MAX_MISSED_PONGS = 2;
|
|
132
|
+
const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || TUNNEL_HEARTBEAT_DEFAULT_MS);
|
|
121
133
|
/**
|
|
122
134
|
* 上游 dsh web 的地址。
|
|
123
135
|
*
|
|
@@ -1402,6 +1414,61 @@ async function resolveToken(refresh = false) {
|
|
|
1402
1414
|
// ============================================================
|
|
1403
1415
|
|
|
1404
1416
|
let tunnelRetry = 0;
|
|
1417
|
+
/** C2:连续收到 close code=4000 的次数(跨 connectTunnel 保留;非 4000 的断开清零)。 */
|
|
1418
|
+
let consecutive4000 = 0;
|
|
1419
|
+
|
|
1420
|
+
/* ── 重连策略(C2 + C5)────────────────────────────────────────────────────────
|
|
1421
|
+
*
|
|
1422
|
+
* code=4000 的语义(中继 relay-router/src/index.mjs 唯一用途):**同 deviceId 的新连接
|
|
1423
|
+
* 把本条连接顶掉了**(replaced by new connection)。所以对被踢的一方,"重连"的唯一效果
|
|
1424
|
+
* 是把对方再顶一次 —— 两个实例就这么以 2~3.5s 的周期乒乓(生产日志 dev-a50dced9cc19
|
|
1425
|
+
* 327 次/10min、win32 持续互踢 6 台里 3 台全 0.7.2)。
|
|
1426
|
+
*
|
|
1427
|
+
* 修法(诊断报告 04 §4.4.2 的 P0-1):
|
|
1428
|
+
* · 第 1 次 4000 → 长退避 30~60s(是原来 2s 的 15~30 倍),让"新连接"先站稳;
|
|
1429
|
+
* · 连续第 2 次 4000 → 直接优雅退出(退出码 4),把设备让给对方。watcher 收到 code=4
|
|
1430
|
+
* 会暂停 5 分钟再拉,避免继续对新实例发起第 3 次顶撞。
|
|
1431
|
+
* · 非 4000 的普通断线保持原有指数退避,但上限 30s→10s 并加 0~3s 抖动(C5):
|
|
1432
|
+
* 30s 上限会让"一次瞬时断网"恢复得比必要慢得多;抖动则避免多台设备同秒重连造成
|
|
1433
|
+
* 中继/nginx 连接尖峰(诊断 R1 的连接耗尽与重连风暴互相放大)。
|
|
1434
|
+
* 抖动只加在 base ≥4s 的档位:第一档(2s)保持确定值,测试里"5 秒内必须重连"的
|
|
1435
|
+
* 硬期限才不会被随机数撞破。
|
|
1436
|
+
*/
|
|
1437
|
+
export const DUPLICATE_CLOSE_CODE = 4000;
|
|
1438
|
+
/** bridge 判定"本机已有另一个同 deviceId 实例"后的退出码(必须与 dsh-setup.mjs 的常量一致)。 */
|
|
1439
|
+
export const DUPLICATE_INSTANCE_EXIT_CODE = 4;
|
|
1440
|
+
export const DUPLICATE_RECONNECT_BASE_MS = 30_000;
|
|
1441
|
+
export const MAX_RECONNECT_DELAY_MS = 10_000;
|
|
1442
|
+
export const RECONNECT_JITTER_MS = 3_000;
|
|
1443
|
+
export const RECONNECT_JITTER_MIN_BASE_MS = 4_000;
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* 算下一次重连:返回 { exit, delayMs, consecutive4000, reason }。
|
|
1447
|
+
* @param {{ code?: number, retry?: number, consecutive4000?: number, rand?: () => number }} args
|
|
1448
|
+
*/
|
|
1449
|
+
export function reconnectPlan({ code, retry = 0, consecutive4000: prev = 0, rand = Math.random } = {}) {
|
|
1450
|
+
if (code === DUPLICATE_CLOSE_CODE) {
|
|
1451
|
+
const next = Math.max(0, prev) + 1;
|
|
1452
|
+
if (next >= 2) return { exit: true, delayMs: 0, consecutive4000: next, reason: "duplicate-exit" };
|
|
1453
|
+
const delayMs = DUPLICATE_RECONNECT_BASE_MS + Math.floor(rand() * DUPLICATE_RECONNECT_BASE_MS);
|
|
1454
|
+
return { exit: false, delayMs, consecutive4000: next, reason: "duplicate-backoff" };
|
|
1455
|
+
}
|
|
1456
|
+
const n = Math.max(0, retry);
|
|
1457
|
+
const base = Math.min(MAX_RECONNECT_DELAY_MS, 2_000 * 2 ** n);
|
|
1458
|
+
const jitter = base >= RECONNECT_JITTER_MIN_BASE_MS ? Math.floor(rand() * RECONNECT_JITTER_MS) : 0;
|
|
1459
|
+
return { exit: false, delayMs: base + jitter, consecutive4000: 0, reason: "normal" };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* 心跳一拍(C5):连续丢 HEARTBEAT_MAX_MISSED_PONGS 次 pong 才判定隧道已死。
|
|
1464
|
+
* 纯函数,便于单测("丢 1 次就断"是这次要改掉的旧契约)。
|
|
1465
|
+
*/
|
|
1466
|
+
export function heartbeatStep({ pongReceived, missedPongs = 0 }) {
|
|
1467
|
+
if (pongReceived) return { missedPongs: 0, terminate: false, ping: true };
|
|
1468
|
+
const missed = Math.max(0, missedPongs) + 1;
|
|
1469
|
+
const terminate = missed >= HEARTBEAT_MAX_MISSED_PONGS;
|
|
1470
|
+
return { missedPongs: missed, terminate, ping: !terminate };
|
|
1471
|
+
}
|
|
1405
1472
|
|
|
1406
1473
|
/** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
|
|
1407
1474
|
function tunnelEndpoint(raw) {
|
|
@@ -1473,6 +1540,7 @@ function connectTunnel(token) {
|
|
|
1473
1540
|
const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
|
|
1474
1541
|
let heartbeat;
|
|
1475
1542
|
let pongReceived = true;
|
|
1543
|
+
let missedPongs = 0;
|
|
1476
1544
|
|
|
1477
1545
|
ws.on("open", () => {
|
|
1478
1546
|
tunnelRetry = 0;
|
|
@@ -1483,16 +1551,21 @@ function connectTunnel(token) {
|
|
|
1483
1551
|
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
1484
1552
|
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
1485
1553
|
heartbeat = setInterval(() => {
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1554
|
+
// C5:连续 2 次没收到 pong 才判定半开(旧实现丢 1 次就断 → 弱网下正常的单次丢包
|
|
1555
|
+
// 也会被当成故障重连,直接贡献了"75% 的隧道会话活不过 60s")。
|
|
1556
|
+
const step = heartbeatStep({ pongReceived, missedPongs });
|
|
1557
|
+
missedPongs = step.missedPongs;
|
|
1558
|
+
pongReceived = false;
|
|
1559
|
+
if (step.terminate) {
|
|
1560
|
+
console.log(`[bridge] 隧道连续 ${missedPongs} 次心跳无响应,主动重连...`);
|
|
1561
|
+
try { ws.terminate(); } catch { /* 已关闭 */ }
|
|
1489
1562
|
return;
|
|
1490
1563
|
}
|
|
1491
|
-
|
|
1564
|
+
if (missedPongs > 0) console.log(`[bridge] 隧道心跳无响应(${missedPongs}/${HEARTBEAT_MAX_MISSED_PONGS}),继续探测...`);
|
|
1492
1565
|
try { ws.ping(); } catch { ws.terminate(); }
|
|
1493
1566
|
}, TUNNEL_HEARTBEAT_MS);
|
|
1494
1567
|
});
|
|
1495
|
-
ws.on("pong", () => { pongReceived = true; });
|
|
1568
|
+
ws.on("pong", () => { pongReceived = true; missedPongs = 0; });
|
|
1496
1569
|
ws.on("message", (raw) => {
|
|
1497
1570
|
const receive = makeFrameReceiver();
|
|
1498
1571
|
try {
|
|
@@ -1520,10 +1593,24 @@ function connectTunnel(token) {
|
|
|
1520
1593
|
clearInterval(heartbeat);
|
|
1521
1594
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
1522
1595
|
closeAllWsSessions();
|
|
1523
|
-
|
|
1596
|
+
// C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
|
|
1597
|
+
const plan = reconnectPlan({ code, retry: tunnelRetry, consecutive4000 });
|
|
1598
|
+
consecutive4000 = plan.consecutive4000;
|
|
1524
1599
|
tunnelRetry += 1;
|
|
1525
|
-
|
|
1526
|
-
|
|
1600
|
+
if (plan.exit) {
|
|
1601
|
+
// 连续两次被顶 = 本机确实还有另一个 bridge 在同一 deviceId 上活着。再重连只会把它再顶一次,
|
|
1602
|
+
// 所以让位:用专用退出码退出,watcher 据此暂停拉起(见 dsh-setup.mjs 的 DUPLICATE_INSTANCE_EXIT_CODE)。
|
|
1603
|
+
console.error("[bridge] 检测到同 deviceId 的另一个 bridge,本实例退出(连续 2 次被顶,不再重连以免互踢)。");
|
|
1604
|
+
console.error("[bridge] 若这是升级/重装留下的旧实例:重启一次 dsh web(或运行 dsh-remote doctor)即可只保留一个。");
|
|
1605
|
+
try { closeAllWsSessions(); } catch { /* 已关闭 */ }
|
|
1606
|
+
process.exit(DUPLICATE_INSTANCE_EXIT_CODE);
|
|
1607
|
+
}
|
|
1608
|
+
if (plan.reason === "duplicate-backoff") {
|
|
1609
|
+
console.log(`[bridge] 本实例被同 deviceId 的新连接顶替,${Math.round(plan.delayMs / 1000)}s 后再试(期间不重连,避免互踢)...`);
|
|
1610
|
+
} else {
|
|
1611
|
+
console.log(`[bridge] ${Math.round(plan.delayMs / 1000)}s 后重连...`);
|
|
1612
|
+
}
|
|
1613
|
+
setTimeout(async () => connectTunnel(code === 4003 ? await resolveToken(true) : token), plan.delayMs);
|
|
1527
1614
|
});
|
|
1528
1615
|
ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
|
|
1529
1616
|
}
|
|
@@ -1757,6 +1844,112 @@ function redactText(e) {
|
|
|
1757
1844
|
return String(e && e.message ? e.message : e).slice(0, 200);
|
|
1758
1845
|
}
|
|
1759
1846
|
|
|
1847
|
+
/* ── C2:同机单实例保护(lock 文件 + PID/命令行校验,失败安全)────────────────────
|
|
1848
|
+
*
|
|
1849
|
+
* 为什么需要(诊断报告 04 §4.4):同机两个 bridge 用同一个 deviceId 连中继会**互相顶号**
|
|
1850
|
+
*(router close(4000)),周期 2~3.5s 的乒乓。来源有孤儿进程(watcher 被 TerminateProcess
|
|
1851
|
+
* 收掉、bridge 漏成孤儿)、升级后旧进程未退、插件自愈与任务计划同时拉。watcher 层的 pid 文件
|
|
1852
|
+
* 管不住这几种,所以 bridge 自己再上一把"本配置目录内只允许一个实例"的锁。
|
|
1853
|
+
*
|
|
1854
|
+
* 判定纪律(误杀/误挡的代价不对称):
|
|
1855
|
+
* · pid 死了 / pid 被系统回收给**无关进程** → 抢锁(陈旧锁绝不能把 bridge 永久挡在门外);
|
|
1856
|
+
* · pid 活着且命令行确认是 `dsh-bridge.mjs` → 判定"已有实例",本实例退出(退出码 4,
|
|
1857
|
+
* watcher 会暂停 5 分钟,见 dsh-setup.mjs);
|
|
1858
|
+
* · 取不到命令行(ps/PowerShell 不可用)→ **放行**(fail-safe 方向):宁可冒着多开的风险也
|
|
1859
|
+
* 不能让"锁检查失败"变成"bridge 永远起不来",多开由上面的 4000 长退避/退出兜底。
|
|
1860
|
+
*/
|
|
1861
|
+
const INSTANCE_LOCK_FILE = ".dsh-bridge-instance.lock";
|
|
1862
|
+
/**
|
|
1863
|
+
* 是否启用单实例锁:生产形态(配置目录运行时 / npm 安装)恒启用;
|
|
1864
|
+
* 仓库里直接 `node clients/dsh-remote/dsh-bridge.mjs` 调试时不落锁,避免把锁文件写进工作区。
|
|
1865
|
+
*/
|
|
1866
|
+
const INSTANCE_GUARD_ENABLED = Boolean(process.env.DSH_BRIDGE_CONFIG) || ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
1867
|
+
|
|
1868
|
+
/** 该 pid 的完整命令行(取不到返回空串 —— 调用方据此走 fail-safe)。 */
|
|
1869
|
+
function commandLineOfPid(pid) {
|
|
1870
|
+
if (!Number.isInteger(pid) || pid <= 0) return "";
|
|
1871
|
+
if (process.platform === "win32") {
|
|
1872
|
+
const script = `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`;
|
|
1873
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
1874
|
+
try {
|
|
1875
|
+
return String(spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
1876
|
+
{ encoding: "utf8", timeout: 8000, windowsHide: true }).stdout || "").trim();
|
|
1877
|
+
} catch { return ""; }
|
|
1878
|
+
}
|
|
1879
|
+
try {
|
|
1880
|
+
return String(execSync(`ps -p ${pid} -o command=`, { encoding: "utf8", timeout: 3000, windowsHide: true }) || "").trim();
|
|
1881
|
+
} catch { return ""; }
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function isOurBridgeCommand(cmd) {
|
|
1885
|
+
const c = String(cmd || "");
|
|
1886
|
+
return /dsh-bridge\.mjs/.test(c) && /clients[\\/]dsh-remote/.test(c);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/**
|
|
1890
|
+
* 锁归属判定(纯函数,便于单测):steal=可以抢锁;blocked=已有真实例;unknown=看不住但别阻塞。
|
|
1891
|
+
* @returns {"steal"|"blocked"|"unknown"}
|
|
1892
|
+
*/
|
|
1893
|
+
export function instanceLockVerdict({ selfPid = 0, pid = 0, alive = false, isOurBridge = false, commandKnown = true } = {}) {
|
|
1894
|
+
if (!Number.isInteger(pid) || pid <= 0) return "steal";
|
|
1895
|
+
if (pid === selfPid) return "steal";
|
|
1896
|
+
if (!alive) return "steal";
|
|
1897
|
+
if (isOurBridge) return "blocked";
|
|
1898
|
+
if (!commandKnown) return "unknown";
|
|
1899
|
+
return "steal"; // pid 被系统回收给了无关进程
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/** 抢单实例锁。返回 { ok, pid?, degraded? };ok=false 表示确实有另一个实例。 */
|
|
1903
|
+
function acquireInstanceLock() {
|
|
1904
|
+
if (!INSTANCE_GUARD_ENABLED) return { ok: true, skipped: true };
|
|
1905
|
+
const file = path.join(RELAY_DIR, INSTANCE_LOCK_FILE);
|
|
1906
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now(), script: "dsh-bridge.mjs" });
|
|
1907
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1908
|
+
try {
|
|
1909
|
+
fs.mkdirSync(RELAY_DIR, { recursive: true });
|
|
1910
|
+
const fd = fs.openSync(file, "wx"); // 排他创建:要么拿到,要么 EEXIST
|
|
1911
|
+
fs.writeSync(fd, payload);
|
|
1912
|
+
fs.closeSync(fd);
|
|
1913
|
+
return { ok: true };
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
if (e && e.code !== "EEXIST") return { ok: true, degraded: true }; // 写不了锁 → 失败安全:照常启动
|
|
1916
|
+
let info = null;
|
|
1917
|
+
try { info = JSON.parse(fs.readFileSync(file, "utf8")); } catch { info = null; }
|
|
1918
|
+
const pid = Number(info && info.pid);
|
|
1919
|
+
const alive = Number.isInteger(pid) && pid > 0 ? isPidAlive(pid) : false;
|
|
1920
|
+
const cmd = alive ? commandLineOfPid(pid) : "";
|
|
1921
|
+
const verdict = instanceLockVerdict({
|
|
1922
|
+
selfPid: process.pid, pid, alive,
|
|
1923
|
+
isOurBridge: isOurBridgeCommand(cmd),
|
|
1924
|
+
commandKnown: Boolean(cmd)
|
|
1925
|
+
});
|
|
1926
|
+
if (verdict === "blocked") return { ok: false, pid };
|
|
1927
|
+
if (verdict === "unknown") {
|
|
1928
|
+
// 看不住:不删别人的锁,也不阻塞自己(多开由 4000 处理兜底)。
|
|
1929
|
+
return { ok: true, degraded: true, pid };
|
|
1930
|
+
}
|
|
1931
|
+
try { fs.rmSync(file, { force: true }); } catch { /* 抢不到就算了,下一轮 */ }
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
return { ok: true, degraded: true };
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/** 释放自己那把锁(只删 owner === 本进程 的锁,绝不删别人的)。 */
|
|
1938
|
+
function releaseInstanceLock() {
|
|
1939
|
+
if (!INSTANCE_GUARD_ENABLED) return;
|
|
1940
|
+
const file = path.join(RELAY_DIR, INSTANCE_LOCK_FILE);
|
|
1941
|
+
try {
|
|
1942
|
+
const info = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1943
|
+
if (Number(info && info.pid) === process.pid) fs.rmSync(file, { force: true });
|
|
1944
|
+
} catch { /* 没锁/已被抢 → 忽略 */ }
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** 本进程 pid 是否活着(不影响别的进程;EPERM 视为活着)。 */
|
|
1948
|
+
function isPidAlive(pid) {
|
|
1949
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1950
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1760
1953
|
async function runTunnel() {
|
|
1761
1954
|
// 先动态确定上游再连隧道:上游端口不是 3080 时(DSH Desktop 默认 43120)这一步是关键 ——
|
|
1762
1955
|
// 否则 bridge 会连上中继、手机也能打开页面,但每个请求都打到一个没人听的端口。
|
|
@@ -1834,6 +2027,18 @@ async function main() {
|
|
|
1834
2027
|
process.on("message", (m) => {
|
|
1835
2028
|
if (m && m.type === "shutdown") shutdown("IPC shutdown");
|
|
1836
2029
|
});
|
|
2030
|
+
// C2:同机单实例锁(理由/判定纪律见 acquireInstanceLock 上方的长注释)。
|
|
2031
|
+
// 放在认证之前:被挡下的第二个实例**不消耗一次 device-login**(登录接口按出口 IP 限流)。
|
|
2032
|
+
const lock = acquireInstanceLock();
|
|
2033
|
+
if (!lock.ok) {
|
|
2034
|
+
console.error(`[bridge] 本机已有另一个 bridge 实例在运行(pid=${lock.pid},同一配置目录),本实例退出以免两个实例互踢。`);
|
|
2035
|
+
console.error("[bridge] 若这来自升级/重装留下的旧实例:结束该进程后重启一次 dsh web 即可。");
|
|
2036
|
+
process.exit(DUPLICATE_INSTANCE_EXIT_CODE); // watcher 据此暂停 5 分钟再拉,避免继续顶撞
|
|
2037
|
+
}
|
|
2038
|
+
if (lock.degraded) {
|
|
2039
|
+
console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
|
|
2040
|
+
}
|
|
2041
|
+
process.on("exit", releaseInstanceLock);
|
|
1837
2042
|
return runTunnel();
|
|
1838
2043
|
}
|
|
1839
2044
|
|
|
@@ -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",
|