dsh-mobile-plugin 0.1.99 → 0.2.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/README.md +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配对入口的限速闸门。
|
|
5
|
+
*
|
|
6
|
+
* 原来那套是「按来源分桶、错五次锁一分钟」,而这条链路上**没有可靠的来源**:经中转进来
|
|
7
|
+
* 的请求,宿主看到的对端永远是本机回环;客户端自带一个来源头又能覆盖它。于是这套机制
|
|
8
|
+
* 两头都错 —— 陌生人连错五次把所有人一起锁死,而换一个来源头就绕过。
|
|
9
|
+
*
|
|
10
|
+
* 换成不看来源的全局令牌桶:
|
|
11
|
+
* - 正常每分钟 60 次,正经用户永远用不到上限;
|
|
12
|
+
* - 连续失败超过 10 次降到 10 次/分钟,超过 30 次降到 2 次/分钟;
|
|
13
|
+
* - 计数按「每 10 分钟减半」自己衰减,一次邻居乱按不会让这台机器长期变慢;
|
|
14
|
+
* - 满了直接回 429 + `retry-after`,**不排队、不硬锁**。队列本身是新的拒绝服务把柄
|
|
15
|
+
* (一个人挂着半条请求就能攥死整条队列),所以这里刻意没有队列。
|
|
16
|
+
*
|
|
17
|
+
* 为什么够了:六位码 100 万种,期望要试五十万次;按最低档 2 次/分钟,一天 2880 次,
|
|
18
|
+
* 期望半年。而任何一次成功都立刻换码(§5.1),所以这把「磨」的有效时间窗只对「一直没人
|
|
19
|
+
* 配成功」的机器成立 —— 那台机器本来也没人等着配。多来源并发也买不到更多次数,因为
|
|
20
|
+
* 桶是全局的。
|
|
21
|
+
*
|
|
22
|
+
* 新形状(带 `sealed`)没有可猜的东西,但它仍然要过这道闸:「解一次封」是无限次可做的
|
|
23
|
+
* 动作,需要有上限。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 三档速率:每分钟允许的尝试次数。 */
|
|
27
|
+
const PAIR_RATE_NORMAL = 60;
|
|
28
|
+
const PAIR_RATE_WARN = 10;
|
|
29
|
+
const PAIR_RATE_FLOOR = 2;
|
|
30
|
+
|
|
31
|
+
/** 连续失败到这些次数就降档。 */
|
|
32
|
+
const PAIR_FAILS_WARN = 10;
|
|
33
|
+
const PAIR_FAILS_FLOOR = 30;
|
|
34
|
+
|
|
35
|
+
/** 失败计数每这么久减半一次(按最后一次失败的时刻算,不做定时器)。 */
|
|
36
|
+
const PAIR_DECAY_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 一道闸的全部状态。**两个入口各持一份**,不是共用:
|
|
40
|
+
*
|
|
41
|
+
* 局域网代理跑在旁路子进程里、宿主那条跑在主进程里,跨进程共用一份计数要么加锁要么走 RPC,
|
|
42
|
+
* 而两者的网络含义本来就不同(一条在局域网上、一条经中转)。各自限自己的就够 ——
|
|
43
|
+
* 攻击者要绕过必须同时打两条,而局域网那条他根本进不来。
|
|
44
|
+
*/
|
|
45
|
+
function createPairThrottle(options = {}) {
|
|
46
|
+
const now = () => (typeof options.now === 'function' ? options.now() : Date.now());
|
|
47
|
+
let tokens = PAIR_RATE_NORMAL;
|
|
48
|
+
let refilledAt = now();
|
|
49
|
+
let fails = 0;
|
|
50
|
+
let lastFailAt = 0;
|
|
51
|
+
|
|
52
|
+
/** 当前档位(每分钟多少次)。 */
|
|
53
|
+
function rate() {
|
|
54
|
+
if (fails >= PAIR_FAILS_FLOOR) return PAIR_RATE_FLOOR;
|
|
55
|
+
if (fails >= PAIR_FAILS_WARN) return PAIR_RATE_WARN;
|
|
56
|
+
return PAIR_RATE_NORMAL;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 按经过的时间补令牌。上限定成「一分钟的量」。 */
|
|
60
|
+
function refill(at) {
|
|
61
|
+
const limit = rate();
|
|
62
|
+
const elapsed = at - refilledAt;
|
|
63
|
+
if (elapsed <= 0) return;
|
|
64
|
+
const gained = (elapsed * limit) / 60_000;
|
|
65
|
+
tokens = Math.min(limit, tokens + gained);
|
|
66
|
+
refilledAt = at;
|
|
67
|
+
// 令牌上限跟着档位走:降档后如果还留着 60 个令牌,降档就形同虚设。
|
|
68
|
+
if (tokens > limit) tokens = limit;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 失败计数的衰减:每当过了一个衰减周期就折半,并且把基准往前推。 */
|
|
72
|
+
function decay(at) {
|
|
73
|
+
if (!lastFailAt) return;
|
|
74
|
+
const periods = Math.floor((at - lastFailAt) / PAIR_DECAY_MS);
|
|
75
|
+
if (periods <= 0) return;
|
|
76
|
+
fails = Math.floor(fails / (2 ** Math.min(periods, 30)));
|
|
77
|
+
lastFailAt += periods * PAIR_DECAY_MS;
|
|
78
|
+
if (fails === 0) lastFailAt = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 试取一个令牌。
|
|
83
|
+
*
|
|
84
|
+
* @returns {{ allowed: true } | { allowed: false, retryAfterSec: number }}
|
|
85
|
+
* `retryAfterSec` 供 429 的 `retry-after` 头用:按当前档位算出「下一个令牌要等多久」。
|
|
86
|
+
*/
|
|
87
|
+
function take() {
|
|
88
|
+
const at = now();
|
|
89
|
+
decay(at);
|
|
90
|
+
refill(at);
|
|
91
|
+
if (tokens >= 1) {
|
|
92
|
+
tokens -= 1;
|
|
93
|
+
return { allowed: true };
|
|
94
|
+
}
|
|
95
|
+
const limit = rate();
|
|
96
|
+
const need = 1 - tokens;
|
|
97
|
+
const seconds = Math.max(1, Math.ceil((need * 60_000) / limit / 1000));
|
|
98
|
+
return { allowed: false, retryAfterSec: seconds };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 记一次失败。 */
|
|
102
|
+
function noteFail() {
|
|
103
|
+
const at = now();
|
|
104
|
+
// 上一次失败已经衰减干净了就先衰减,免得跨档位累计。
|
|
105
|
+
decay(at);
|
|
106
|
+
fails += 1;
|
|
107
|
+
lastFailAt = at;
|
|
108
|
+
// 降档立刻生效:不把旧档位攒下的令牌留着用。
|
|
109
|
+
const limit = rate();
|
|
110
|
+
if (tokens > limit) tokens = limit;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 成功:清零并回到最高档。 */
|
|
114
|
+
function noteSuccess() {
|
|
115
|
+
fails = 0;
|
|
116
|
+
lastFailAt = 0;
|
|
117
|
+
const at = now();
|
|
118
|
+
const limit = PAIR_RATE_NORMAL;
|
|
119
|
+
refill(at);
|
|
120
|
+
if (tokens > limit) tokens = limit;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 完全重置成满桶(测试用;`noteSuccess` 只清失败计数,不清已用掉的令牌)。 */
|
|
124
|
+
function reset() {
|
|
125
|
+
refilledAt = now();
|
|
126
|
+
fails = 0;
|
|
127
|
+
lastFailAt = 0;
|
|
128
|
+
tokens = PAIR_RATE_NORMAL;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 测试与自检用:当前状态快照。 */
|
|
132
|
+
function snapshot() {
|
|
133
|
+
const at = now();
|
|
134
|
+
decay(at);
|
|
135
|
+
refill(at);
|
|
136
|
+
return { rate: rate(), tokens, fails };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { take, noteFail, noteSuccess, snapshot, reset };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
createPairThrottle,
|
|
144
|
+
PAIR_RATE_NORMAL,
|
|
145
|
+
PAIR_RATE_WARN,
|
|
146
|
+
PAIR_RATE_FLOOR,
|
|
147
|
+
PAIR_FAILS_WARN,
|
|
148
|
+
PAIR_FAILS_FLOOR,
|
|
149
|
+
PAIR_DECAY_MS,
|
|
150
|
+
};
|
package/lib/shared/sse-write.js
CHANGED
|
@@ -169,14 +169,23 @@ function writeSse(res, payload, options = {}) {
|
|
|
169
169
|
padEveryMs = 0,
|
|
170
170
|
retryMs = SSE_RETRY_MS,
|
|
171
171
|
shedWhenBuffered = shedWithoutSeq,
|
|
172
|
+
/**
|
|
173
|
+
* 协议 v2 的帧封装器:给出就说明这条流会说话的那一半已经加密(§8.3(5))。
|
|
174
|
+
*
|
|
175
|
+
* 它返回 `{ v, n, c }` 形状的信封;`id:` 与 `retry:` 保持明文 —— 它们是 SSE 协议层
|
|
176
|
+
* 字段,客户端要靠 `id:` 回填 `Last-Event-ID`,而 `id:` 里的 sessionId 本来就在 URL 上。
|
|
177
|
+
*/
|
|
178
|
+
sealFrame = null,
|
|
172
179
|
} = options;
|
|
173
180
|
const buffered = bufferedBytesOf(res);
|
|
174
181
|
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
175
|
-
if (buffered > SSE_SHED_BUFFERED_BYTES && shedWhenBuffered(payload)) return { ok: true, shed: true };
|
|
176
|
-
const id = stableEventId(payload, frame, namespace);
|
|
182
|
+
if (buffered > SSE_SHED_BUFFERED_BYTES && shedWhenBuffered(payload)) return { ok: true, shed: true }; const id = stableEventId(payload, frame, namespace);
|
|
177
183
|
const fields = [`retry: ${retryMs}`];
|
|
178
184
|
if (id) fields.push(`id: ${id}`);
|
|
179
|
-
|
|
185
|
+
// 每帧一条独立密文(自己的 nonce):整条流套一个信封就得等流结束才能解,那等于把
|
|
186
|
+
// 流式取消了;一帧一密文也让「丢一帧」不至于让后面全部解不开。
|
|
187
|
+
const wire = typeof sealFrame === 'function' ? sealFrame(payload) : payload;
|
|
188
|
+
fields.push(`data: ${JSON.stringify(wire)}`);
|
|
180
189
|
const extra = padding || (paddingBytes > 0
|
|
181
190
|
// 按字节算:正文是中文时字符数只有字节数三分之一,按字符算会低估、补多。
|
|
182
191
|
? takePadding(res, Buffer.byteLength(`${fields.join('\n')}\n`, 'utf8'), Math.floor(paddingBytes), Math.floor(padEveryMs))
|
|
@@ -186,8 +195,10 @@ function writeSse(res, payload, options = {}) {
|
|
|
186
195
|
try {
|
|
187
196
|
// `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
|
|
188
197
|
// 堆起来。把它当成功吞掉,正是那次 4GB OOM 的直接原因。
|
|
189
|
-
const
|
|
190
|
-
|
|
198
|
+
const frameText = `${fields.join('\n')}\n`;
|
|
199
|
+
const accepted = res.write(frameText);
|
|
200
|
+
// 字节数回给调用方:断线重放要按字节封顶(事件条数封不住「每帧都很大」的会话)。
|
|
201
|
+
return { ok: true, needDrain: accepted === false, bytes: Buffer.byteLength(frameText, 'utf8') };
|
|
191
202
|
} catch { return { ok: false }; }
|
|
192
203
|
}
|
|
193
204
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 代理转发时给上游请求打「谁转发的」标记。
|
|
5
|
+
*
|
|
6
|
+
* 中转与局域网代理都跑在宿主这台机器上,它们转给本地上游的请求,socket 对端是回环地址、
|
|
7
|
+
* `host` 又是 `127.0.0.1:<上游端口>`,所以上游**任何**看来源地址或 Host 的判断都会把它们
|
|
8
|
+
* 当成「本机 DSH 设置页」。控制面因此是敞开的:不带实例头、不带 cookie 的
|
|
9
|
+
* `POST /api/relay/config` 经中转能改中转地址、改注册密钥、`revoke-all`。
|
|
10
|
+
*
|
|
11
|
+
* 判据只能落在「谁转发的」这个事实上,而这个头必须**由代理覆盖写入**:先删客户端自带的
|
|
12
|
+
* 同名副本(大小写不敏感、数组形态也删干净),再写自己的值。只写不删等于让客户端自己声明
|
|
13
|
+
* 「我是本机」。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const VIA_HEADER = 'x-dsh-via';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 这条请求是从哪条路转发进来的(`relay` / `lan`),本机直连返回空串。
|
|
20
|
+
*
|
|
21
|
+
* 设备列表要显示「最近一次走的是哪条路」,而这两条路最后都进到自己的代理里、
|
|
22
|
+
* 各自注入了标记 —— 所以宿主不用问中转,也不用等别的版本。
|
|
23
|
+
*/
|
|
24
|
+
function viaChannel(req) {
|
|
25
|
+
const headers = req?.headers;
|
|
26
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
27
|
+
for (const [name, raw] of Object.entries(headers)) {
|
|
28
|
+
if (name.toLowerCase() !== VIA_HEADER) continue;
|
|
29
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
30
|
+
const text = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
31
|
+
return text === 'relay' || text === 'lan' ? text : '';
|
|
32
|
+
}
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 删掉任意大小写的同名头(Node 的 headers 可能是数组值)。 */
|
|
37
|
+
function stripHeader(headers, name) {
|
|
38
|
+
const target = name.toLowerCase();
|
|
39
|
+
let removed = false;
|
|
40
|
+
for (const key of Object.keys(headers)) {
|
|
41
|
+
if (key.toLowerCase() !== target) continue;
|
|
42
|
+
delete headers[key];
|
|
43
|
+
removed = true;
|
|
44
|
+
}
|
|
45
|
+
return removed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 复制一份转发用头:原头不动,去掉客户端自带的来源标记,写入代理自己的值。
|
|
50
|
+
*
|
|
51
|
+
* @param {Record<string, unknown>} incoming 客户端原始请求头
|
|
52
|
+
* @param {'relay'|'lan'} via 转发方
|
|
53
|
+
* @param {string} fallbackHost 客户端没给 host 时用哪个
|
|
54
|
+
* @returns {Record<string, unknown>}
|
|
55
|
+
*/
|
|
56
|
+
function withViaHeader(incoming, via, fallbackHost) {
|
|
57
|
+
const headers = { ...(incoming || {}) };
|
|
58
|
+
stripHeader(headers, VIA_HEADER);
|
|
59
|
+
// 保留手机端原始 Host(信任围栏按 Host 判定);缺省回退本地上游。
|
|
60
|
+
if (!headers.host) headers.host = fallbackHost;
|
|
61
|
+
headers[VIA_HEADER] = via;
|
|
62
|
+
return headers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { VIA_HEADER, stripHeader, withViaHeader, viaChannel };
|
package/lib/shell/runtime.js
CHANGED
|
@@ -20,11 +20,18 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { importGeneration, invalidateGeneration, resolveGeneration, resolveGenerations } from './manager.js'
|
|
23
|
+
import { checkGenerationAlive } from './self-check-after-swap.js'
|
|
23
24
|
|
|
24
25
|
/** 换代前等在飞请求归零的上限。 */
|
|
25
26
|
const DRAIN_TIMEOUT_MS = 2000
|
|
26
27
|
/** 等一代装载完成的上限。装载卡住(依赖永远不来)时不能把整个外壳拖死。 */
|
|
27
28
|
const ACTIVATE_TIMEOUT_MS = 20000
|
|
29
|
+
/** 首次装载全失败后的重试间隔与次数(重试理由见 `start()`)。 */
|
|
30
|
+
const START_RETRY_LIMIT = 6
|
|
31
|
+
/** 间隔可用环境变量压低,只给测试(与 `DSH_MOBILE_PROFILE_NODE_MODULES` 同款逃生口)。 */
|
|
32
|
+
const START_RETRY_MS = Number(process.env.DSH_MOBILE_START_RETRY_MS) > 0
|
|
33
|
+
? Number(process.env.DSH_MOBILE_START_RETRY_MS)
|
|
34
|
+
: 30000
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
37
|
* 代没有声明 `inject` 时的兜底(只有测试里的假代会走这里)。
|
|
@@ -61,6 +68,14 @@ export function createRuntime() {
|
|
|
61
68
|
let lastError = null
|
|
62
69
|
let activatedAt = null
|
|
63
70
|
let detached = false
|
|
71
|
+
/**
|
|
72
|
+
* 最近一次「装上了、但新版自检没过,于是自己退回去」的记录。
|
|
73
|
+
*
|
|
74
|
+
* 为什么要它:这件事**不能靠手机按回滚** —— 装坏的后果是 App 连不上这台设备,那一刻
|
|
75
|
+
* 手机上任何按钮都发不出去。所以退回是宿主自己做的,而手机只能在**下一次连上之后**
|
|
76
|
+
* 读到这条已经发生过的事实(`lastApply.rolledBack` 就是从它来的)。
|
|
77
|
+
*/
|
|
78
|
+
let lastRollback = null
|
|
64
79
|
|
|
65
80
|
/** 对外状态快照。控制面接口直接把它交给客户端。 */
|
|
66
81
|
function status() {
|
|
@@ -72,6 +87,7 @@ export function createRuntime() {
|
|
|
72
87
|
lastError,
|
|
73
88
|
inFlight,
|
|
74
89
|
swapping: swapping !== null,
|
|
90
|
+
lastRollback,
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
93
|
|
|
@@ -113,6 +129,70 @@ export function createRuntime() {
|
|
|
113
129
|
return { version: loaded.version, source: loaded.source, hostPath: loaded.hostPath ?? dir ?? null }
|
|
114
130
|
}
|
|
115
131
|
|
|
132
|
+
/**
|
|
133
|
+
* 换代之后问一次自己的 `/health`:这一代真的在跑、路由真的挂上了吗?
|
|
134
|
+
*
|
|
135
|
+
* 端口取**运行中那一份**(`ctx.webServer.port`),不是配置里的默认值 —— 端口被改过之后
|
|
136
|
+
* 两者可能不同,按配置去问会问到一个没人监听的地方,于是「自检失败」全是假的。
|
|
137
|
+
* 拿不到端口时返回 `skip` 而不是失败:那种现场(老宿主、测试里的假 ctx)不代表新版坏了。
|
|
138
|
+
*/
|
|
139
|
+
async function checkSelfCheck() {
|
|
140
|
+
let port = null
|
|
141
|
+
try { port = ctx?.webServer?.port ?? null } catch { port = null }
|
|
142
|
+
return checkGenerationAlive({ port })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 把上一代装回去,并记下「退回过」这件事。
|
|
147
|
+
*
|
|
148
|
+
* 顺序是刻意的:**先用内存里那份**(`loadedCurrent`),因为它一定还在;盘上那份可能
|
|
149
|
+
* 已经被这次安装带走了(pnpm 换 store 目录),照版本号找会找空。
|
|
150
|
+
*
|
|
151
|
+
* 记 `lastRollback` 是给手机的:它不按回滚,只在**下一次连上之后**读到这条已经发生过
|
|
152
|
+
* 的事实(`lastApply.rolledBack`)。容器里带 `ok`,退失败也要如实说。
|
|
153
|
+
*/
|
|
154
|
+
async function rollbackTo(oldCurrent, oldLoaded, reason) {
|
|
155
|
+
let ok = false
|
|
156
|
+
try {
|
|
157
|
+
if (oldLoaded) {
|
|
158
|
+
const { fiber: nextFiber } = await activateLoaded(oldLoaded)
|
|
159
|
+
fiber = nextFiber
|
|
160
|
+
loadedCurrent = oldLoaded
|
|
161
|
+
current = describe(oldLoaded, oldCurrent?.dir ?? null)
|
|
162
|
+
activatedAt = new Date().toISOString()
|
|
163
|
+
ok = true
|
|
164
|
+
} else {
|
|
165
|
+
const fallback = resolveGeneration(oldCurrent?.version)
|
|
166
|
+
if (fallback) {
|
|
167
|
+
const { loaded, fiber: nextFiber } = await activate(fallback)
|
|
168
|
+
fiber = nextFiber
|
|
169
|
+
loadedCurrent = loaded
|
|
170
|
+
current = describe(loaded, fallback.dir)
|
|
171
|
+
activatedAt = new Date().toISOString()
|
|
172
|
+
ok = true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (rollbackError) {
|
|
176
|
+
lastError = `${reason};回滚到 ${oldCurrent?.version ?? '上一代'} 也失败:${rollbackError?.message || rollbackError}`
|
|
177
|
+
lastRollback = {
|
|
178
|
+
at: new Date().toISOString(),
|
|
179
|
+
ok: false,
|
|
180
|
+
toVersion: null,
|
|
181
|
+
fromVersion: previous?.version ?? null,
|
|
182
|
+
reason,
|
|
183
|
+
}
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
lastRollback = {
|
|
187
|
+
at: new Date().toISOString(),
|
|
188
|
+
ok,
|
|
189
|
+
toVersion: ok ? (current?.version ?? null) : null,
|
|
190
|
+
fromVersion: previous?.version ?? null,
|
|
191
|
+
reason,
|
|
192
|
+
}
|
|
193
|
+
return ok
|
|
194
|
+
}
|
|
195
|
+
|
|
116
196
|
/**
|
|
117
197
|
* 换到指定版本(默认版本最高的那一代)。
|
|
118
198
|
*
|
|
@@ -145,6 +225,9 @@ export function createRuntime() {
|
|
|
145
225
|
await drain()
|
|
146
226
|
const oldFiber = fiber
|
|
147
227
|
const oldCurrent = current
|
|
228
|
+
// 旧的**模块本体**要在激活新代之前抓住:`loadedCurrent` 一旦被新代覆盖,回滚就只能
|
|
229
|
+
// 拿到刚装坏的那一份(真机上表现为「自检没过、退回去的还是坏的那个」)。
|
|
230
|
+
const oldLoaded = loadedCurrent
|
|
148
231
|
fiber = null
|
|
149
232
|
current = null
|
|
150
233
|
if (oldFiber) {
|
|
@@ -163,34 +246,37 @@ export function createRuntime() {
|
|
|
163
246
|
if (oldCurrent) previous = oldCurrent
|
|
164
247
|
activatedAt = new Date().toISOString()
|
|
165
248
|
lastError = null
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (fallback) {
|
|
183
|
-
const { loaded, fiber: nextFiber } = await activate(fallback)
|
|
184
|
-
fiber = nextFiber
|
|
185
|
-
loadedCurrent = loaded
|
|
186
|
-
current = describe(loaded, fallback.dir)
|
|
187
|
-
activatedAt = new Date().toISOString()
|
|
188
|
-
}
|
|
249
|
+
// 装起来了不等于**手机要打的那批接口**可用:端口没绑上、守卫没挂上、某个 handler
|
|
250
|
+
// 永远不回包,这些都要真打一发才看得见。所以换完之后问一次自己的 `/health`。
|
|
251
|
+
//
|
|
252
|
+
// 不过就自己退回去 —— 这一步刻意放在宿主里,不交给手机:装坏的后果正是 App
|
|
253
|
+
// 连不上这台设备,那时候手机上一颗按钮都发不出去。
|
|
254
|
+
if (options.selfCheck !== false && oldCurrent) {
|
|
255
|
+
const alive = await checkSelfCheck()
|
|
256
|
+
if (alive.ok === false) {
|
|
257
|
+
const failure = `新版 ${target.version} 换上来之后没通过自检:${alive.reason}`
|
|
258
|
+
// **先把刚装起来的新代卸掉**:它占着同一批路由路径,不卸就装不回旧代
|
|
259
|
+
// (webserver: duplicate exact route)。这条是自检回滚与「装载直接抛错」两条路
|
|
260
|
+
// 唯一的区别 —— 后者根本没有新 fiber 可卸。
|
|
261
|
+
const brokenFiber = fiber
|
|
262
|
+
fiber = null
|
|
263
|
+
if (brokenFiber) {
|
|
264
|
+
try { await brokenFiber.dispose() } catch { /* 新代自己卸不干净时,回滚仍然照试 */ }
|
|
189
265
|
}
|
|
190
|
-
|
|
191
|
-
|
|
266
|
+
await rollbackTo(oldCurrent, oldLoaded, failure)
|
|
267
|
+
const error = new Error(failure)
|
|
268
|
+
error.code = 'self_check_failed'
|
|
269
|
+
throw error
|
|
192
270
|
}
|
|
193
271
|
}
|
|
272
|
+
return status()
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// 自检那条已经在上面退回过一次了,别退第二遍(第二遍会把刚装回去的旧代再卸一次)。
|
|
275
|
+
// 但它记下的「退回也失败」那句话不能在这里被覆盖掉 —— 那才是真机上最要紧的一条。
|
|
276
|
+
if (error?.code !== 'self_check_failed') {
|
|
277
|
+
lastError = `装载 ${target.version} 失败:${error?.message || error}`
|
|
278
|
+
if (oldCurrent) await rollbackTo(oldCurrent, oldLoaded, lastError)
|
|
279
|
+
}
|
|
194
280
|
throw error
|
|
195
281
|
}
|
|
196
282
|
})().finally(() => {
|
|
@@ -199,28 +285,47 @@ export function createRuntime() {
|
|
|
199
285
|
return swapping
|
|
200
286
|
}
|
|
201
287
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
288
|
+
/**
|
|
289
|
+
* 第一次激活;最高代装载失败时按优先级尝试保留的回退代;全失败则按固定间隔重试有限次。
|
|
290
|
+
*
|
|
291
|
+
* 为什么要重试(2026-10-25 真机 404):一次失败 = **整张控制面路由表都不在**,而设置页
|
|
292
|
+
* 的「移动端」照样画得出来(客户端半边按包扫描,不看宿主 fiber 活没活),于是现场只表现成
|
|
293
|
+
* 「读取宿主状态失败: HTTP 404」。失败最常见的原因恰好是**可能自己好**的:注入的服务
|
|
294
|
+
* (typertGateway / apiProxy)比本插件晚就位、撞上 20s 装载上限,或者第一次装载撞上
|
|
295
|
+
* IO/时序抖动。一次失败就永久躺平到下次重启,等于把一个瞬态问题固定成故障。
|
|
296
|
+
*/
|
|
297
|
+
async function start(context, options = {}) {
|
|
204
298
|
ctx = context
|
|
299
|
+
const log = typeof options.log === 'function' ? options.log : () => {}
|
|
205
300
|
const candidates = resolveGenerations()
|
|
206
301
|
if (!candidates.length) throw new Error('找不到任何一代实现:包内的 lib/impl.js 缺失')
|
|
207
|
-
|
|
208
|
-
for (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
302
|
+
let lastFailure = ''
|
|
303
|
+
for (let attempt = 1; attempt <= START_RETRY_LIMIT; attempt += 1) {
|
|
304
|
+
const failures = []
|
|
305
|
+
for (const target of candidates) {
|
|
306
|
+
if (detached) throw new Error('插件已经卸载,不再重试装载')
|
|
307
|
+
try {
|
|
308
|
+
const { loaded, fiber: startedFiber } = await activate(target)
|
|
309
|
+
fiber = startedFiber
|
|
310
|
+
loadedCurrent = loaded
|
|
311
|
+
current = describe(loaded, target.dir)
|
|
312
|
+
activatedAt = new Date().toISOString()
|
|
313
|
+
lastError = failures.length
|
|
314
|
+
? `${failures.join(';')};已回退到 ${target.version}(${target.source})`
|
|
315
|
+
: null
|
|
316
|
+
return status()
|
|
317
|
+
} catch (error) {
|
|
318
|
+
failures.push(`装载 ${target.version}(${target.source})失败:${error?.message || error}`)
|
|
319
|
+
}
|
|
221
320
|
}
|
|
321
|
+
lastFailure = `所有可用插件实现均装载失败:${failures.join(';')}`
|
|
322
|
+
if (attempt >= START_RETRY_LIMIT) break
|
|
323
|
+
lastError = `${lastFailure}(第 ${attempt} 次,${START_RETRY_MS / 1000}s 后重试)`
|
|
324
|
+
log('warn', 'dsh-mobile-plugin: 装载失败,%d 秒后重试:%s', START_RETRY_MS / 1000, lastFailure)
|
|
325
|
+
await sleep(START_RETRY_MS)
|
|
222
326
|
}
|
|
223
|
-
|
|
327
|
+
lastError = lastFailure
|
|
328
|
+
throw new Error(lastFailure)
|
|
224
329
|
}
|
|
225
330
|
|
|
226
331
|
/** 等在飞请求结束(最多 `DRAIN_TIMEOUT_MS`)。 */
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 换代之后的自检:新版装起来之后,**手机要打的那批接口**真的能用吗?不能就退回上一代。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么这一条不能交给手机
|
|
5
|
+
*
|
|
6
|
+
* 装坏的后果是「App 连不上这台设备」。那一刻手机上没有任何按钮发得出去 —— 让 App 去按
|
|
7
|
+
* 「回滚」等于让一个已经断线的客户端去救自己的连接。所以这件事必须由**还活着的宿主进程**
|
|
8
|
+
* 自己做完,手机那边只在事后读到一条「上次装失败了,已自动退回 X」。
|
|
9
|
+
*
|
|
10
|
+
* ## 判据:问它自己的 `/health`,看回的是什么
|
|
11
|
+
*
|
|
12
|
+
* 不看日志、不猜堆栈,直接打本机的 `127.0.0.1:<宿主端口>`:
|
|
13
|
+
*
|
|
14
|
+
* · 回 200 + `service: 'dsh-mobile-plugin'` → 活,路由挂上了;
|
|
15
|
+
* · 回 401 → 也活:移动端守卫先核 token 再谈路由,没带 token 回 401 说明实现与守卫都在;
|
|
16
|
+
* · 回 404 / 别的 service → **死了**:端口在、但插件那批路由没挂上,手机打过去就是这个 404;
|
|
17
|
+
* · 超时 → **死了**:连上了但不回包,多半是某个 handler 卡在等一个永远不来的依赖;
|
|
18
|
+
* · 连不上(ECONNREFUSED)→ **不算数**:那是宿主 webServer 自己没起来,与新代无关,
|
|
19
|
+
* 退回去也救不了。如实报 `skip`。
|
|
20
|
+
*
|
|
21
|
+
* 最后那一条是这次刻意分开的:把「宿主没在监听」当成「新版坏了」会退掉一次其实成功的
|
|
22
|
+
* 更新(开发机上跑测试时就是这么一种现场:假 webServer 有个端口号,但没有任何东西在听)。
|
|
23
|
+
*
|
|
24
|
+
* ## 端口从哪儿来
|
|
25
|
+
*
|
|
26
|
+
* 用宿主的 `ctx.webServer.port`(运行中那一份),不是配置里的默认值 —— 端口被改过之后,
|
|
27
|
+
* 配置与实绑可能不同,按配置去问会问到一个没人监听的地方,于是「自检失败」全是假的。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 自检的超时上限:本机环回,正常几十毫秒。给 3 秒已经很宽。 */
|
|
31
|
+
export const SELF_CHECK_TIMEOUT_MS = 3000
|
|
32
|
+
|
|
33
|
+
/** 请求的那条路径:与 App 首屏打的是同一条(见 lib/routes/mobile-api.js)。 */
|
|
34
|
+
export const SELF_CHECK_PATH = '/api/mobile/v1/health'
|
|
35
|
+
|
|
36
|
+
/** 服务标识:宿主自己的 /health 回的就是这个(中转回 'dsh-mobile-relay')。 */
|
|
37
|
+
export const SELF_CHECK_SERVICE = 'dsh-mobile-plugin'
|
|
38
|
+
|
|
39
|
+
/** 连接层失败(宿主 webServer 没起来)的错误码。 */
|
|
40
|
+
const CONNECTION_ERRORS = new Set(['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'EADDRNOTAVAIL'])
|
|
41
|
+
|
|
42
|
+
/** 从 fetch 抛出来的东西里挖出底层 syscall 错误码(undici 把它塞在 cause 里)。 */
|
|
43
|
+
function connectionCodeOf(error) {
|
|
44
|
+
let cursor = error
|
|
45
|
+
for (let depth = 0; depth < 4 && cursor; depth += 1) {
|
|
46
|
+
if (typeof cursor.code === 'string' && CONNECTION_ERRORS.has(cursor.code)) return cursor.code
|
|
47
|
+
cursor = cursor.cause
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 问一次:这一代实现活着吗?
|
|
54
|
+
*
|
|
55
|
+
* @param {object} options
|
|
56
|
+
* @param {number} options.port 宿主 webServer 实绑端口
|
|
57
|
+
* @param {number} [options.timeoutMs]
|
|
58
|
+
* @param {typeof fetch} [options.fetchImpl] 注入只为测试
|
|
59
|
+
* @returns {Promise<{ok: true} | {skip: true, reason: string} | {ok: false, reason: string}>}
|
|
60
|
+
*/
|
|
61
|
+
export async function checkGenerationAlive({ port, timeoutMs = SELF_CHECK_TIMEOUT_MS, fetchImpl = fetch } = {}) {
|
|
62
|
+
const value = Number(port)
|
|
63
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
64
|
+
return { skip: true, reason: '拿不到宿主端口,跳过自检' }
|
|
65
|
+
}
|
|
66
|
+
const controller = new AbortController()
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetchImpl(`http://127.0.0.1:${value}${SELF_CHECK_PATH}`, {
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
})
|
|
73
|
+
if (response.status === 401) return { ok: true }
|
|
74
|
+
if (response.status === 404) {
|
|
75
|
+
return { ok: false, reason: `自检接口 404:新版没有把 ${SELF_CHECK_PATH} 挂上` }
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) return { ok: false, reason: `自检接口回了 HTTP ${response.status}` }
|
|
78
|
+
const body = await response.json().catch(() => null)
|
|
79
|
+
if (!body || body.service !== SELF_CHECK_SERVICE) {
|
|
80
|
+
return { ok: false, reason: `自检接口回的不是本插件(${body && body.service ? String(body.service) : '没有 service 字段'})` }
|
|
81
|
+
}
|
|
82
|
+
return { ok: true }
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error?.name === 'AbortError') {
|
|
85
|
+
return { ok: false, reason: `自检超时(${timeoutMs}ms):插件起来了但接口没应答` }
|
|
86
|
+
}
|
|
87
|
+
const code = connectionCodeOf(error)
|
|
88
|
+
if (code) return { skip: true, reason: `宿主 webServer 没在 ${value} 端口上监听(${code}),与新版无关,跳过自检` }
|
|
89
|
+
return { ok: false, reason: `自检请求失败:${String(error?.message || error)}` }
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
}
|
|
93
|
+
}
|
package/lib/shell/updater.js
CHANGED
|
@@ -375,18 +375,37 @@ async function runUpdate({ runtime, base, hostService = () => null, updateState,
|
|
|
375
375
|
let swapped = false
|
|
376
376
|
let swapFailure = null
|
|
377
377
|
let deferred = false
|
|
378
|
+
/**
|
|
379
|
+
* 换代之后自检没过、外壳自己退回去了。
|
|
380
|
+
*
|
|
381
|
+
* 判据与动作都在 `runtime.js`(它才知道新版到底装起来了没有),这里只把那条**已经发生
|
|
382
|
+
* 过的事实**抄进这一轮的账。手机端不按回滚 —— 装坏的后果正是 App 连不上,那时候它按不了
|
|
383
|
+
* 任何东西 —— 它只用这条记录在**下一次连上之后**如实显示「上次装失败了,已退回 X」。
|
|
384
|
+
*/
|
|
385
|
+
let rollback = null
|
|
378
386
|
if (outcome.installed === true || newerOnDisk) {
|
|
379
387
|
const attempt = await swapTo(runtime, disk ?? runningBefore, { auto, log })
|
|
380
388
|
swapped = attempt.swapped
|
|
381
389
|
swapFailure = attempt.failure
|
|
382
390
|
deferred = attempt.deferred === true
|
|
391
|
+
rollback = attempt.rollback ?? null
|
|
383
392
|
}
|
|
393
|
+
const rolledBack = Boolean(rollback && rollback.ok)
|
|
384
394
|
const lastApply = {
|
|
385
395
|
...outcome,
|
|
386
396
|
installed: outcome.installed === true,
|
|
387
397
|
swapped,
|
|
388
|
-
state:
|
|
389
|
-
|
|
398
|
+
state: rolledBack
|
|
399
|
+
? 'rolled-back'
|
|
400
|
+
: swapFailure ? 'installed-not-swapped' : (outcome.state ?? (outcome.installed || outcome.skipped ? 'succeeded' : 'failed')),
|
|
401
|
+
// 退回去了的话,真正失败的原因是自检那句话,而不是「换代码抛错」那个笼统说法。
|
|
402
|
+
failure: rolledBack
|
|
403
|
+
? { code: 'self_check_failed', message: rollback.reason }
|
|
404
|
+
: (swapFailure ?? outcome.failure ?? null),
|
|
405
|
+
/** 装上了、但新版没通过自检,于是外壳自己退回了上一代。 */
|
|
406
|
+
rolledBack,
|
|
407
|
+
/** 退回之后跑着的版本(退成功才有)。 */
|
|
408
|
+
runningAfterRollback: rolledBack ? (rollback.toVersion ?? null) : null,
|
|
390
409
|
// 换代由外壳就地做,市场那套「装完要重启」在本项目一律不照做。只为如实上报。
|
|
391
410
|
restartRequired: false,
|
|
392
411
|
refreshRequired: false,
|
|
@@ -422,14 +441,29 @@ async function swapTo(runtime, target, { auto = false, log = () => {} } = {}) {
|
|
|
422
441
|
}
|
|
423
442
|
}
|
|
424
443
|
swapAttempts.set(key, Math.max(tried, 1))
|
|
444
|
+
let rollback = null
|
|
425
445
|
try {
|
|
426
446
|
const status = await runtime.reload()
|
|
427
447
|
swapAttempts.delete(key)
|
|
448
|
+
// 外壳把「装起来了但自检没过、于是自己退回去」记在 `lastRollback` 上,而不是抛错
|
|
449
|
+
// (它退成功了,进程还活着)。这里把它捞出来,交给这一轮的账 —— 手机端要读的就是它。
|
|
450
|
+
rollback = status?.lastRollback ?? null
|
|
451
|
+
if (rollback && rollback.ok) {
|
|
452
|
+
log('warn', 'dsh-mobile-plugin: 新版自检没过,已退回 %s:%s', rollback.toVersion, rollback.reason)
|
|
453
|
+
return {
|
|
454
|
+
swapped: false,
|
|
455
|
+
rollback,
|
|
456
|
+
failure: { code: 'self_check_failed', message: rollback.reason },
|
|
457
|
+
}
|
|
458
|
+
}
|
|
428
459
|
log('info', 'dsh-mobile-plugin: 已就地换到 %s(无需重启 DSH)', status?.version ?? key)
|
|
429
460
|
return { swapped: true, status }
|
|
430
461
|
} catch (error) {
|
|
462
|
+
// 新版装载直接抛错:runtime 已经按老规矩回滚过一代了,那条回滚也记在 `lastRollback`。
|
|
463
|
+
try { rollback = runtime.status()?.lastRollback ?? null } catch { rollback = null }
|
|
431
464
|
return {
|
|
432
465
|
swapped: false,
|
|
466
|
+
rollback,
|
|
433
467
|
failure: { code: 'swap_failed', message: `新版已经装到盘上(${key}),但换代码没成:${String(error?.message || error)}` },
|
|
434
468
|
}
|
|
435
469
|
}
|