dsh-mobile-plugin 0.4.0 → 0.4.4
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 +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +47 -24
- package/host.js +22 -36
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +13 -25
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -12
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/allowed-zone.js +100 -0
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +13 -21
- package/lib/mobile/attachments.js +16 -37
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/file-read.js +168 -0
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +85 -134
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +48 -23
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +6 -23
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +136 -33
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-cwd.js +28 -0
- package/lib/mobile/session-file.js +9 -33
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +154 -37
- package/lib/mobile/stats-ledger.cjs +258 -75
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +39 -130
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +75 -21
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +9 -12
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +39 -47
- package/lib/routes/mobile-api.js +47 -34
- package/lib/session-cleanup.cjs +26 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/device-headers.js +12 -4
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +15 -28
- package/lib/sidecar-facade-worker.cjs +75 -41
- package/lib/sidecar-facade.cjs +18 -8
- package/lib/sidecar-lifecycle.cjs +21 -10
- package/lib/update-install.cjs +24 -62
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
package/lib/read-receipt.cjs
CHANGED
|
@@ -3,30 +3,19 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 「已读回执」台账:哪个会话在什么时候被读过,桌面端据此清掉自己的未读绿点。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - 桌面端绿点 = 浏览器渲染进程内存里的 `UiSession.completionUnread`
|
|
10
|
-
* (@deepseek-ai/dsh-client-ui-session),既不落盘也不过服务端。
|
|
11
|
-
* 所以手机上点开一个会话,桌面那份完全不知情,绿点照旧。
|
|
6
|
+
* 两端的绿点来自两份互不相干的状态:手机端是宿主按设备维度维护的完成提醒(completion.js),
|
|
7
|
+
* 桌面端是渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。所以手机上
|
|
8
|
+
* 点开一个会话,桌面那份完全不知情。
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
|
|
17
|
-
* `reconcileStatus()` 把 `completionUnread` 摘掉。
|
|
10
|
+
* 这里补那条通道:只要有人在某一端读过,宿主就记一条 `sessionId → readAt` 水位;客户端半边
|
|
11
|
+
* 每 2 秒拉一次,对水位前进过的会话做一次瞬时 retain + release,借桌面自己的
|
|
12
|
+
* `reconcileStatus()` 把绿点摘掉。
|
|
18
13
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* 重启就空 —— 所以 `readAtOf` 让它在重启后从这盘上补回来。两头都写同一本账,
|
|
22
|
-
* 「读过」这件事才不会随进程消失。
|
|
14
|
+
* 宿主自己也读这本账:`readAtOf` 让进程重启后的已读水位从盘上补回来,两头写同一本账,
|
|
15
|
+
* 「读过」才不会随进程消失。
|
|
23
16
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* **所有设备**维度上的完成提醒(`clearCompletionForAllDevices`)。而「读完又来一轮」
|
|
27
|
-
* 必须还能重新亮起来 —— 这一条由 completion.js 的 delete 语义本身保证:条目被清掉后
|
|
28
|
-
* 重建出来的 `wasRunning` 是 false,历史折叠会拒绝确认,于是只有**之后**真正跑完的
|
|
29
|
-
* 轮次才会把 completed 重新置真。
|
|
17
|
+
* 存 readAt 而不是布尔:冲突策略是 last-write-wins;「读完又来一轮」由 completion.js 的
|
|
18
|
+
* 条目重建语义保证能重新亮起来。
|
|
30
19
|
*/
|
|
31
20
|
|
|
32
21
|
const fs = require('node:fs');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「按新配置重建隧道失败」的台账:最近一次失败的错误、时间与当时要切过去的地址。
|
|
5
|
+
*
|
|
6
|
+
* 以前装配层写的是 `void syncSidecar().catch(() => undefined)`:换地址失败被整个吞掉,
|
|
7
|
+
* 手机上看是「保存成功」然后永久离线,宿主日志里也一个字都没有 —— 用户唯一的线索是「连不上
|
|
8
|
+
* 了」,还得自己猜是不是地址填错了。台账只留最近一条,由
|
|
9
|
+
* `/api/mobile/v1/relay/diagnostics` 原样报给手机(「运行状态」那一页读它)。
|
|
10
|
+
*
|
|
11
|
+
* 只留一条是刻意的:这里回答的是「刚才那次为什么没成」,不是故障统计(故障统计归
|
|
12
|
+
* `host-guard` 的 faults)。成功一次记下 `okAt`(那一次 configure 被 worker 接下了),好让
|
|
13
|
+
* 界面能说清「失败过,后来又好了」;隧道到底连上没有,看同一份诊断里的 `tunnel`。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 台账里 message 的上限:与 faults 同一条口径,手机上只看一句话。 */
|
|
17
|
+
const MESSAGE_MAX = 300;
|
|
18
|
+
|
|
19
|
+
let lastFailure = null;
|
|
20
|
+
let lastOkAt = 0;
|
|
21
|
+
|
|
22
|
+
/** 记一次失败。`target` 是当时配置里那份要切过去的中转地址(没有就不带)。 */
|
|
23
|
+
function recordFailure(error, target) {
|
|
24
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
25
|
+
lastFailure = {
|
|
26
|
+
at: Date.now(),
|
|
27
|
+
// 只留第一行:facade 拼出来的错误后面可能跟着整段堆栈首行,手机上没用。
|
|
28
|
+
message: String(raw || '').split('\n')[0].slice(0, MESSAGE_MAX),
|
|
29
|
+
...(target ? { target: String(target) } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 记一次成功下发:失败记录不删(它确实发生过),只补上「之后又通了」的时间。 */
|
|
34
|
+
function recordSuccess() {
|
|
35
|
+
lastOkAt = Date.now();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 只读投影:诊断接口原样回这一份。 */
|
|
39
|
+
function view() {
|
|
40
|
+
return {
|
|
41
|
+
lastFailure: lastFailure ? { ...lastFailure } : null,
|
|
42
|
+
okAt: lastOkAt || null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 清空(只有测试用得到;生产里失败记录由下一次失败覆盖)。 */
|
|
47
|
+
function reset() {
|
|
48
|
+
lastFailure = null;
|
|
49
|
+
lastOkAt = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { recordFailure, recordSuccess, view, reset, MESSAGE_MAX };
|
package/lib/relay/core.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* relay.json 的状态形状与读写,外加实例 ID / 配对码 / 隧道地址这几个纯函数。
|
|
3
3
|
*
|
|
4
|
-
* 内存里的 `state` 是本进程唯一真值,这里只负责落盘与还原(见 A3-20
|
|
4
|
+
* 内存里的 `state` 是本进程唯一真值,这里只负责落盘与还原(见 A3-20 的坑)。中转地址的
|
|
5
|
+
* 规范形态(`normalizeRelayUrl`)也在这里:配置里、设备手上、`MIGRATE` 帧里必须是同一份
|
|
6
|
+
* 字符串,否则「地址变了」会被判错,迁移登记白跑一次。
|
|
5
7
|
*/
|
|
6
8
|
'use strict';
|
|
7
9
|
|
|
@@ -87,6 +89,15 @@ function saveState(state) {
|
|
|
87
89
|
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
88
90
|
fs.renameSync(tmp, STATE_FILE);
|
|
89
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* 中转地址的规范形态:去首尾空白、去尾斜杠。
|
|
94
|
+
*
|
|
95
|
+
* 设备手上存的那份、`MIGRATE` 帧里报出去的 `to`、配置里落盘的那份,必须是同一个字符串 ——
|
|
96
|
+
* 差一个尾斜杠就是「地址变了」,换地址那套迁移登记会白跑一次。
|
|
97
|
+
*/
|
|
98
|
+
function normalizeRelayUrl(relayUrl) {
|
|
99
|
+
return String(relayUrl || '').trim().replace(/\/+$/, '');
|
|
100
|
+
}
|
|
90
101
|
/**
|
|
91
102
|
* 中转地址 → 隧道 WebSocket 地址。
|
|
92
103
|
*
|
|
@@ -96,7 +107,7 @@ function saveState(state) {
|
|
|
96
107
|
* `/api/relay/config` 能在保存时就给出明确反馈。
|
|
97
108
|
*/
|
|
98
109
|
function tunnelWsUrl(relayUrl) {
|
|
99
|
-
const raw =
|
|
110
|
+
const raw = normalizeRelayUrl(relayUrl);
|
|
100
111
|
if (!/^https?:\/\/[^\s/]+/i.test(raw)) {
|
|
101
112
|
throw new Error(`无效的中转地址「${relayUrl || ''}」:必须以 http:// 或 https:// 开头`);
|
|
102
113
|
}
|
|
@@ -107,5 +118,5 @@ function verifyLanToken(secret, token, instanceId) { return sharedToken.verifyLa
|
|
|
107
118
|
|
|
108
119
|
module.exports = {
|
|
109
120
|
STATE_FILE, MOBILE_TOKEN_TTL_SEC, MOBILE_BEARER_TTL_SEC, loadState, saveState, rotatePairing, genInstanceId, genPairingCode, genPairingTicket,
|
|
110
|
-
tunnelWsUrl, signLanToken, verifyLanToken,
|
|
121
|
+
tunnelWsUrl, normalizeRelayUrl, signLanToken, verifyLanToken,
|
|
111
122
|
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 网卡枚举与候选地址:私网 / 公网 / IPv6 全球单播三份清单,外加「用户选中的那条排最前」。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 收口在一个文件里,是因为 App 只认「按顺序挨个试」这一种语义,顺序就是优先级。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `ipv6Candidates` 的注释)。
|
|
6
|
+
* 三份的区别在手机在哪:私网是「同一个 Wi-Fi 里能直连」,公网是「这台机器自己就挂在公网上」,
|
|
7
|
+
* IPv6 是「手机在别的网络里拨过来」。IPv6 不并进前两份:App 对它的用法不同,要和公网中转
|
|
8
|
+
* 比速度。
|
|
10
9
|
*/
|
|
11
10
|
'use strict'
|
|
12
11
|
|
|
@@ -22,17 +21,11 @@ function ipv4Number(address) {
|
|
|
22
21
|
function inRange(value, start, end) { return value >= start && value <= end }
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
* RFC1918
|
|
24
|
+
* RFC1918 里不由容器 / 虚拟机 / 虚拟网桥占掉的那几段。
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `172.19/16` 与 `172.20/16`(compose / k3d 的下一批)、`172.24~172.31/16`(kubeadm 的
|
|
31
|
-
* `k8s-pod-network` 与常见 overlay)也都是工具按默认值抢走的。
|
|
32
|
-
*
|
|
33
|
-
* 判据是「这个段是不是被某个常见的非宿主网络工具预留了」,而不是「这台机器上有没有跑它」:
|
|
34
|
-
* 我们读不到网络配置,只能按默认值收敛。留下的 172.16~172.23 是 172.16/12 里没被任何
|
|
35
|
-
* 主流工具默认占用的部分;真有人在这几段上跑业务网,网卡名黑名单(下一条)仍然是第一道闸。
|
|
26
|
+
* Docker 默认拿走 172.17/16,compose / k3d / kubeadm 又按默认值各占几段;把它们当候选,
|
|
27
|
+
* 手机拨过去要么不通,要么连到容器上的别的东西。我们读不到网络配置,只能按「常见工具会不会
|
|
28
|
+
* 默认占用」收敛;网卡名黑名单仍然是第一道闸。
|
|
36
29
|
*/
|
|
37
30
|
const RESERVED_172 = [
|
|
38
31
|
[0xac110000, 0xac11ffff], // 172.17/16 docker0
|
|
@@ -175,21 +168,14 @@ function lanCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
175
168
|
}
|
|
176
169
|
|
|
177
170
|
/**
|
|
178
|
-
*
|
|
171
|
+
* 公网地址:同一遍扫描,挑出真能从外网拨过来的那些。
|
|
179
172
|
*
|
|
180
173
|
* 判据是「不在任何特殊网段里」,而不是「不是 RFC1918」——后者会把 169.254/16(链路本地)、
|
|
181
174
|
* 100.64/10(CGNAT)、198.18/15(基准测试)这些同样拨不通的地址当成公网地址发出去。
|
|
182
|
-
* 实测踩过:macOS 上一块多出来的网卡带着 `169.254.87.131`,被当成「公网地址」摆到面板上。
|
|
183
|
-
* 这份特殊网段清单和本文件开头那句「intentionally excludes VPN/CGNAT, link-local and
|
|
184
|
-
* benchmarking/test ranges」是同一条口径。
|
|
185
|
-
*
|
|
186
|
-
* 为什么不并进 `lanInterfaces`:那份列表的语义是「手机和这台机器在同一个 Wi-Fi 里能直连的
|
|
187
|
-
* 地址」,混进公网地址会改掉 App 的探测顺序与中转上报的内容(`test/lan-network.test.cjs`
|
|
188
|
-
* 既钉着「WAF 容器的 192.168.0.1 被当成候选」那次事故,也钉着「只有公网地址的机器候选为空」)。
|
|
189
175
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
176
|
+
* 不并进 `lanInterfaces`:那份的语义是「同一 Wi-Fi 里能直连」,混进公网地址会改掉 App 的
|
|
177
|
+
* 探测顺序与中转上报的内容。需要公网地址的只有两处:面板显示该填哪个地址,以及一台只有
|
|
178
|
+
* 公网 IP 的机器上给不出任何私网候选时的兜底。
|
|
193
179
|
*/
|
|
194
180
|
const NOT_PUBLIC_RANGES = [
|
|
195
181
|
[0x00000000, 0x00ffffff], // 0/8 “本网络”
|
|
@@ -328,8 +314,8 @@ function ipv6Candidates(port, interfaces = os.networkInterfaces()) {
|
|
|
328
314
|
* 对外公布的候选地址:**先私网,一个都没有时才退到公网**。
|
|
329
315
|
*
|
|
330
316
|
* 为什么要有这一条:`lanInterfaces` 只认 RFC1918,于是「DSH 装在一台只有公网 IP 的服务器上」
|
|
331
|
-
* 时那份列表**恒为空** ——
|
|
332
|
-
*
|
|
317
|
+
* 时那份列表**恒为空** —— 二维码里一个可拨的地址都没有,「手机直连」这条路从根上走不通。
|
|
318
|
+
* 只在为空时才退:有私网地址的机器,候选列表一个字都不变。
|
|
333
319
|
*
|
|
334
320
|
* 取不到网卡(异常)时回空清单、绝不抛:它喂的是二维码与移动端的连接信息,
|
|
335
321
|
* 让它把整条控制面拖下水不值得。
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* 局域网直连代理:手机在同一个 Wi-Fi 下直接访问
|
|
5
5
|
* http://<电脑IP>:<lanPort>,带上配对拿到的令牌后代理到本机 dsh web。
|
|
6
6
|
*
|
|
7
|
-
* 只服务 App:未认证的请求一律 JSON 401
|
|
8
|
-
* 与配对后能进入的网页本体已删除(见 docs/decisions.md 2026-10-19)。
|
|
7
|
+
* 只服务 App:未认证的请求一律 JSON 401,不给任何网页(网页版配对页与网页本体已删除)。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* 与中转隧道的关系:同网走本代理(零延迟、不依赖公网),异地走 dsh-mobile-server 中转;
|
|
10
|
+
* 两者互不影响,可同时开启。
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
const http = require('node:http');
|
|
@@ -352,22 +351,18 @@ function createLanProxy(opts) {
|
|
|
352
351
|
/**
|
|
353
352
|
* 手机走了,必须把上游这条请求一起拆掉。
|
|
354
353
|
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* 宿主进程涨到 3.5~5G、CPU 30~40%,重启后回到 530M。同一个插件的中转路径有
|
|
359
|
-
* `httpCancel` 帧负责拆(tunnel.js),只有局域网这条路漏了。
|
|
354
|
+
* 只挂 `error` 的话,手机断开后上游请求还活着,宿主那边的响应永远等不到 `close`,events
|
|
355
|
+
* 流与每条会话流的订阅就永不退订,宿主继续往一条没人读的连接上生成帧与正文。中转路径有
|
|
356
|
+
* `httpCancel` 帧负责拆,局域网这条路同样要有。
|
|
360
357
|
*
|
|
361
|
-
* `res.writableEnded
|
|
362
|
-
* 只有「客户端提前走」才会落到 false。
|
|
358
|
+
* 判据是 `res.writableEnded`:正常活完的响应结束时就已置位,只有客户端提前走才落到 false。
|
|
363
359
|
*/
|
|
364
360
|
res.on('close', () => {
|
|
365
361
|
if (res.writableEnded) return;
|
|
366
362
|
try { up.destroy() } catch { /* 已断开 */ }
|
|
367
363
|
});
|
|
368
364
|
// GET/HEAD 没有请求体:直接收尾发出去,不要依赖 `req` 的 `end` 事件 —— SSE 客户端的
|
|
369
|
-
// 那条连接未必会像普通 fetch 一样把请求流收掉,`pipe` 一直不 end
|
|
370
|
-
// (2026-10-24 现场:设备级 SSE 每 32 秒重试,宿主侧一次都没进处理器)。
|
|
365
|
+
// 那条连接未必会像普通 fetch 一样把请求流收掉,`pipe` 一直不 end,上游就永远不处理。
|
|
371
366
|
if (req.method === 'GET' || req.method === 'HEAD') up.end();
|
|
372
367
|
else req.pipe(up);
|
|
373
368
|
}
|
|
@@ -783,16 +778,14 @@ function createLanProxy(opts) {
|
|
|
783
778
|
})
|
|
784
779
|
|
|
785
780
|
/**
|
|
786
|
-
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` +
|
|
781
|
+
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` + 显式 `ipv6Only: true`。
|
|
787
782
|
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
* 之后 v6 那个 socket 不碰 IPv4(不设这个标志在 Linux 上才会撞),同端口可以共存。
|
|
783
|
+
* 不合成一条双栈监听:`impl.js` 的端口占用探测真去 `listen(port, '0.0.0.0')` 试探,
|
|
784
|
+
* 一条双栈的 `::` 会把首选端口一律探成「被占」,于是落到内核随机端口、端口每次启动都
|
|
785
|
+
* 可能漂;把探测也改成 `::`,关掉 IPv6 的机器上又会因 EAFNOSUPPORT 永远判「被占」。
|
|
786
|
+
* 各管一个协议族就没这个问题。
|
|
793
787
|
*
|
|
794
|
-
*
|
|
795
|
-
* 只留 IPv4 直连——绝不影响 IPv4,也绝不往上抛。
|
|
788
|
+
* 它是软的:只有「手机不在同一 Wi-Fi、只有 IPv6」时才用得上,绑不上就记一行、只留 IPv4。
|
|
796
789
|
*/
|
|
797
790
|
const startV6 = () => new Promise((onDone) => {
|
|
798
791
|
const v6 = http.createServer(handleRequest);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 换中转地址前的「搬迁登记」:在**旧隧道**上告诉中转「这台搬家到 to」,给帧留出真的离开
|
|
5
|
+
* 本机的时间,再建新隧道;新隧道注册不上就把那条登记收回来(`to:""`)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么非得在旧隧道上说:设备手上握的是旧地址,它只会在旧中转上找这台电脑。不先登记,
|
|
8
|
+
* 换完地址那一刻,所有不在同一个局域网的设备就永远找不到它了 —— 「换服务器之后拿旧地址的
|
|
9
|
+
* 设备永远学不到新地址」就是缺这一帧。
|
|
10
|
+
*
|
|
11
|
+
* 旧隧道**不立刻关**(两条路都一样):新隧道注册期间它还在服务在途请求;失败时更是只有它
|
|
12
|
+
* 还能发撤销。注册完成后再留 `OLD_TUNNEL_LINGER_MS`,让「已受理」或「失败」那一发响应
|
|
13
|
+
* 沿着老路回去 —— 这一发的结果是用户填完地址之后唯一的反馈。
|
|
14
|
+
*
|
|
15
|
+
* 顺序只有这一份实现:worker 的 `configure()` 在检测到中转地址变了之后调 `switchRelay`,
|
|
16
|
+
* 手机端与控制面两个入口都走同一条下发路径,不各写一套。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { T } = require('./protocol.js');
|
|
20
|
+
const core = require('./core.js');
|
|
21
|
+
|
|
22
|
+
/** MIGRATE 帧发出后留给它离开发送队列的时间:一次 `ws.send` 就进内核,200ms 足够。 */
|
|
23
|
+
const MIGRATE_FLUSH_MS = 200;
|
|
24
|
+
|
|
25
|
+
/** 新隧道注册成功后旧隧道还留多久:够一发 HTTP 响应(含中转那段)沿着旧路回完。 */
|
|
26
|
+
const OLD_TUNNEL_LINGER_MS = 1500;
|
|
27
|
+
|
|
28
|
+
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
29
|
+
|
|
30
|
+
/** 登记用的目标地址:与配置、设备手上那份同一个规范形态。 */
|
|
31
|
+
function migrateTarget(relayUrl) {
|
|
32
|
+
return core.normalizeRelayUrl(relayUrl);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 在旧隧道上登记迁移;没连着就回 false(那就没什么可登记的)。 */
|
|
36
|
+
function announce(tunnel, relayUrl) {
|
|
37
|
+
if (!tunnel || !tunnel.connected) return false;
|
|
38
|
+
return tunnel.sendJson(T.migrate, 0, { to: migrateTarget(relayUrl), at: Date.now() }) === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 撤销登记(`to` 空串 = 这台没搬走)。 */
|
|
42
|
+
function revoke(tunnel) {
|
|
43
|
+
if (!tunnel || !tunnel.connected) return false;
|
|
44
|
+
return tunnel.sendJson(T.migrate, 0, { to: '', at: Date.now() }) === true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 换地址:登记 → 等帧出去 → 起新隧道 → 成功则稍后收旧隧道 / 失败则撤销并抛错。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {object|null} opts.oldTunnel 旧隧道(可能没连上,那就只是重建)
|
|
52
|
+
* @param {string} opts.relayUrl 要切过去的中转地址
|
|
53
|
+
* @param {() => Promise<unknown>} opts.open 建一条**新**隧道并返回它的注册 Promise;
|
|
54
|
+
* 不许先停旧的(旧的还要发撤销、还要把响应送回去)
|
|
55
|
+
* @param {(tunnel: object) => void} opts.drop 停掉一条隧道
|
|
56
|
+
* @returns {Promise<boolean>} 旧隧道上到底登记出去没有
|
|
57
|
+
*/
|
|
58
|
+
async function switchRelay({ oldTunnel, relayUrl, open, drop }) {
|
|
59
|
+
/** 留一段再收旧隧道:那一发「已受理 / 失败原因」的响应还在它上面往回走。 */
|
|
60
|
+
const dropLater = (tunnel) => {
|
|
61
|
+
if (!tunnel) return;
|
|
62
|
+
const linger = setTimeout(() => { try { drop(tunnel); } catch { /* 已经断开 */ } }, OLD_TUNNEL_LINGER_MS);
|
|
63
|
+
if (typeof linger.unref === 'function') linger.unref();
|
|
64
|
+
};
|
|
65
|
+
const announced = announce(oldTunnel, relayUrl);
|
|
66
|
+
// 帧必须先真的离开发送队列:紧接着建新隧道(以及失败后的撤销)都和这一帧的到达顺序有关。
|
|
67
|
+
if (announced) await wait(MIGRATE_FLUSH_MS);
|
|
68
|
+
let registered;
|
|
69
|
+
try {
|
|
70
|
+
registered = await open();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// 新地址没通:把登记收回去,旧中转别再把设备往一个连不上的地址送。
|
|
73
|
+
revoke(oldTunnel);
|
|
74
|
+
// 撤销之后也别立刻关:错误要沿旧隧道回到发起这次切换的那一发请求上 ——「如实回给
|
|
75
|
+
// 调用方」只有这一条物理路径。
|
|
76
|
+
dropLater(oldTunnel);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
dropLater(oldTunnel);
|
|
80
|
+
return announced && Boolean(registered);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
switchRelay,
|
|
85
|
+
announce,
|
|
86
|
+
revoke,
|
|
87
|
+
migrateTarget,
|
|
88
|
+
MIGRATE_FLUSH_MS,
|
|
89
|
+
OLD_TUNNEL_LINGER_MS,
|
|
90
|
+
};
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 配对授权策略。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 返回 { approved: false } 的行为完全一致。
|
|
6
|
+
* 无头宿主没有弹窗确认界面,授权改由本地策略决定。两条链路都汇聚到父进程的
|
|
7
|
+
* `/api/relay/internal/approval`:隧道收到 pairRequest 时,以及局域网代理的 onApprove。
|
|
8
|
+
* sidecar 只接受显式 true,所以 'deny' 与恒定返回 `{ approved: false }` 等价。
|
|
10
9
|
*
|
|
11
|
-
* 'code' —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* /api/relay/regenerate 轮换、用 /api/relay/revoke-all 连同 lanSecret
|
|
15
|
-
* 一并作废。
|
|
10
|
+
* 'code' — 凭配对码放行。两条链路在询问宿主之前都已用恒定时间比较校验过配对码,中转还有
|
|
11
|
+
* 失败锁定;配对码只在用户主动索取时展示,可用 `/api/relay/regenerate` 轮换、
|
|
12
|
+
* 用 `/api/relay/revoke-all` 连同 lanSecret 一并作废。
|
|
16
13
|
* 'deny' — 拒绝一切新配对。
|
|
17
14
|
*/
|
|
18
15
|
|
package/lib/relay/preview.js
CHANGED
|
@@ -3,34 +3,17 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 预览字节管道(宿主侧)。
|
|
5
5
|
*
|
|
6
|
-
* 手机 App
|
|
7
|
-
*
|
|
8
|
-
* 段跑在宿主进程里的搬运工——就是这里。
|
|
6
|
+
* 手机 App 的内置浏览器要看到桌面机上「开发中的网页」,而那些 dev server 只监听宿主的
|
|
7
|
+
* 127.0.0.1:<port>,手机、中转、局域网代理都够不着。所以链路上有一段跑在宿主进程里的搬运工。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 建立管道时协商一次,之后这管道对内容一无所知,只搬字节。
|
|
9
|
+
* 是字节管道而不是 HTTP 代理:一个页面是几百次请求加一条活着的 HMR WebSocket,端口在建立
|
|
10
|
+
* 管道时协商一次,之后完全不解析内容。于是内置浏览器的地址栏与实际 Host 都是
|
|
11
|
+
* `localhost:3001`,dev server 生成的绝对地址自动闭环、零配置;做路径改写反而会立刻全 404。
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
* 的 dev server 看到的 Host 也是 `localhost:3001`——两边对「我在哪」的认知完全一致。
|
|
17
|
-
* 于是 dev server 生成的一切绝对地址(资源路径、接口地址、跳转 Location、HMR 连接
|
|
18
|
-
* 地址)都自动闭环,**dev server 零配置**。反过来说,如果这里做半吊子的路径改写,
|
|
19
|
-
* `/assets/app.js` 这类绝对路径立刻全 404。
|
|
13
|
+
* 环回地址要双栈试(macOS 上不少 dev server 只监听 `::1`)。WS 升级、SSE、分块传输原样穿过。
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - 手机侧的原生模块把 WebView 写下的原始字节塞进 WebSocket 的二进制帧;
|
|
24
|
-
* - 这里 `net.connect(<环回地址>, port)` 之后双向对搬。环回地址要**双栈**试:macOS 上
|
|
25
|
-
* 不少 dev server(Vite 等)默认只监听 `::1`,只拨 `127.0.0.1` 会连不上;
|
|
26
|
-
* - 中间任何一层都不解析、不改写,所以 WS 升级、SSE、分块传输、自定义子协议全都
|
|
27
|
-
* 原样穿过(HMR 不用砍也不用绕——浏览器和 Vite 自己完成握手与分帧)。
|
|
28
|
-
*
|
|
29
|
-
* 代价要说清楚:正因为宿主无法审查内容,**端口就是这里唯一的边界**,所以下面有
|
|
30
|
-
* `protectedPorts()` 那道按身份的拒绝(DSH 自身的 web 端口永远不许从预览进来)。
|
|
31
|
-
*
|
|
32
|
-
* 与仓库里 `dsh-mobile-server/scripts/adb-ws-bridge.cjs` 是同一个套路(原始 TCP 流包进 WebSocket 帧),
|
|
33
|
-
* 那个先例证明这条路在本项目里走得通。
|
|
15
|
+
* 代价:宿主无法审查内容,端口就是唯一边界,所以 `protectedPorts()` 按身份拒绝——DSH 自身的
|
|
16
|
+
* web 端口永远不许从预览进来。
|
|
34
17
|
*/
|
|
35
18
|
|
|
36
19
|
const net = require('node:net');
|
package/lib/relay/protocol.js
CHANGED
|
@@ -3,20 +3,16 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* dsh-mobile-server 隧道协议 v2 编解码(取代 v1 的 JSON/base64 文本帧)。
|
|
5
5
|
*
|
|
6
|
-
* 每条隧道消息 = 一个 WS binary
|
|
7
|
-
*
|
|
6
|
+
* 每条隧道消息 = 一个 WS binary 帧:`[type: 1B][id: 4B 大端][length: 4B 大端][payload]`。
|
|
7
|
+
* 数据通道的 payload 是原始字节(不再 base64),控制消息是 UTF-8 JSON,ping/pong 为应用层
|
|
8
|
+
* 心跳(经代理/负载均衡时比 WS 原生 ping 可靠)。
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - 控制消息(register / httpHead 等)payload 为 UTF-8 JSON;
|
|
12
|
-
* - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
|
|
10
|
+
* 控制帧 `migrate`(0x1C)是「这台搬家了」的登记:宿主在**旧隧道**上发出,中转据此把还
|
|
11
|
+
* 握着旧地址的设备导向新地址,`to` 空串表示撤销。发送与撤销的时序见 relay/migrate.js。
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 与编解码实现。类型表的事实源在独立中转仓库的 server/relay-core/protocol-types.js;
|
|
18
|
-
* 本仓的 protocol-parity 测试钉住线格式,中转仓库另做 Rust 与 JS 字节对撞;
|
|
19
|
-
* 改动帧编号后必须同步修改本文件。
|
|
13
|
+
* 本文件是自包含副本(插件包不能跨目录引用中转仓库):完整内联了共用类型表与编解码实现。
|
|
14
|
+
* 事实源在中转仓库的 relay-core,本仓的 protocol-parity 测试钉住线格式;改动帧编号后必须
|
|
15
|
+
* 同步修改本文件。
|
|
20
16
|
*/
|
|
21
17
|
|
|
22
18
|
const T = {
|
|
@@ -47,6 +43,7 @@ const T = {
|
|
|
47
43
|
revokeAllAck: 0x19, // id=0 JSON {ok, revoked};中转确认已清空
|
|
48
44
|
pushRegister: 0x1A, // id=0 JSON {deviceId, platform, token};宿主代 App 上报 iOS 推送令牌(本实例)
|
|
49
45
|
pushSend: 0x1B, // id=0 JSON {deviceId, title, body, sessionId?, kind};宿主请求中转发一条 APNs 推送
|
|
46
|
+
migrate: 0x1C, // id=0 JSON {to, at};宿主在旧隧道上登记「这台搬去哪个中转」,to 空串 = 撤销
|
|
50
47
|
};
|
|
51
48
|
|
|
52
49
|
const TYPE_NAME = {};
|
package/lib/relay/status.js
CHANGED
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 中转状态视图:把「此刻这台宿主到底怎么样」拼成一份 JSON。
|
|
5
5
|
*
|
|
6
|
-
* 这份 JSON
|
|
7
|
-
* `/api/mobile/v1/status
|
|
8
|
-
*
|
|
9
|
-
* 某个字段拼对没有,只能通读 1000 行装配代码。抽出来之后可以直接 require 单元测试,
|
|
10
|
-
* 也让 index.js 只剩装配。
|
|
6
|
+
* 这份 JSON 有三个消费方,形状必须一模一样:桌面设置页、手机首屏都轮询
|
|
7
|
+
* `/api/mobile/v1/status`,二维码里嵌的也是它的子集。抽出来之后可以直接 require 测试,
|
|
8
|
+
* index.js 只剩装配。
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 当前唯一穿过隧道边界回来的信息。判定放宽到只认关键词,见下。
|
|
10
|
+
* `TUNNEL_ADMISSION` 靠正则匹配中转返回的错误文案来判断隧道为什么被拒。这是个脆弱契约
|
|
11
|
+
* (中转改一个字就漏判),但错误文案是当前唯一穿过隧道边界回来的信号,判定放宽到只认关键词。
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
const os = require('node:os')
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -44,18 +44,17 @@ class RelayTunnel {
|
|
|
44
44
|
* @param {object} opts
|
|
45
45
|
* @param {string} opts.relayUrl 隧道 WSS 地址(含 /tunnel),如 wss://relay.example.com/tunnel
|
|
46
46
|
* @param {string} opts.instanceId
|
|
47
|
-
* @param {string} [opts.pairingCode]
|
|
47
|
+
* @param {string} [opts.pairingCode] 仅本进程自用(面板显示),不发往中转
|
|
48
48
|
* @param {string} [opts.name]
|
|
49
49
|
* @param {string[]} [opts.lanUrls] LAN sidecar candidates (metadata only)
|
|
50
50
|
* @param {object[]} [opts.lanCandidates] LAN candidate metadata (metadata only)
|
|
51
51
|
* @param {string} opts.upstream 本地上游,如 http://127.0.0.1:52323
|
|
52
|
-
* @param {string} [opts.registerSecret] 中转侧 TUNNEL_REGISTER_SECRET
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* @param {string} [opts.registerSecret] 中转侧 TUNNEL_REGISTER_SECRET 的对偶值,优先于环境
|
|
53
|
+
* 变量 DSH_RELAY_REGISTER_SECRET:桌面版从图标拉起时拿不到 shell 环境变量,密钥只能走
|
|
54
|
+
* relay.json(0600)。
|
|
55
55
|
* @param {() => void} [opts.onRegistered] 注册成功回调(用于状态展示)
|
|
56
56
|
* @param {(code: number, reason: string) => void} [opts.onClose] 连接断开回调(用于断线重连)
|
|
57
|
-
* @param {(msg:
|
|
58
|
-
* 中转转发来的配对授权请求(手机配对码已通过校验,需桌面用户确认)
|
|
57
|
+
* @param {(msg: object) => void} [opts.onPairRequest] 中转转发来的配对授权请求(配对码已校验)
|
|
59
58
|
* @param {(msg: string) => void} [opts.onLog]
|
|
60
59
|
*/
|
|
61
60
|
constructor(opts) {
|
|
@@ -110,15 +109,11 @@ class RelayTunnel {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
/**
|
|
113
|
-
*
|
|
112
|
+
* 发送队列积压到上限时只丢这一帧,不断开隧道。
|
|
114
113
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* 的 sendFrame)已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
|
|
119
|
-
* 关一条隧道是全网断流。
|
|
120
|
-
*
|
|
121
|
-
* 真正的「隧道已死」由心跳兜底:PONG_TIMEOUT_MS 收不到 pong 时才会 terminate。
|
|
114
|
+
* 积压本身是「手机/中转读得慢」,不是隧道坏了;掐掉整条隧道会让所有设备一起掉线再等 5s
|
|
115
|
+
* 重连。中转侧的同名判定已经改成只 `return false`,两侧口径必须一致:丢一帧可以恢复,
|
|
116
|
+
* 关一条隧道是全网断流。真正的「隧道已死」由心跳兜底:收不到 pong 才 terminate。
|
|
122
117
|
*/
|
|
123
118
|
dropOverBudget(bytes, frameLength) {
|
|
124
119
|
const total = bytes + frameLength;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 试连中转:拿**新地址**真注册一次,注册上了才算这个地址能用,随后立刻断开。
|
|
5
|
+
*
|
|
6
|
+
* 手机和电脑面板上改中转地址,最坏的结果是这台电脑从网上消失 —— 而改地址的入口本身就挂在
|
|
7
|
+
* 那条隧道上。先落盘再重建等于让用户在一次手滑之后自己走回电脑前,所以改之前必须先验证:
|
|
8
|
+
* 这个动作就是那个验证。
|
|
9
|
+
*
|
|
10
|
+
* **临时实例 id**:中转按实例 id 认人,拿正式 id 去试连会把正在服务的那条顶掉(真机上表现
|
|
11
|
+
* 为「点了一下验证,手机立刻掉线」)。所以用正式 id 加 `~probe~` 加随机段,连完马上断,
|
|
12
|
+
* 不在中转上留实例。随机段不是防御性加盐:同一个实例连第二次探测时,中转那条旧记录还没被
|
|
13
|
+
* 心跳清掉,撞上就会互相取代。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { randomBytes } = require('node:crypto');
|
|
17
|
+
|
|
18
|
+
const { RelayTunnel } = require('./tunnel.js');
|
|
19
|
+
const core = require('./core.js');
|
|
20
|
+
|
|
21
|
+
/** 临时实例 id 里的标记段:出错时一眼能看出这不是一台真的宿主。 */
|
|
22
|
+
const PROBE_MARK = '~probe~';
|
|
23
|
+
|
|
24
|
+
/** 试连的实例 id:正式 id + 标记 + 随机段,必然与正式 id 不同。 */
|
|
25
|
+
function probeInstanceId(instanceId) {
|
|
26
|
+
return `${String(instanceId || '')}${PROBE_MARK}${randomBytes(4).toString('hex')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 用 `instanceId`(调用方给的是临时 id)连 `relayUrl` 并等到 `registered` 帧。
|
|
31
|
+
*
|
|
32
|
+
* resolve = 这个地址登录得了;reject = 地址形状不对、连不上、注册被拒或超时(消息原样往上
|
|
33
|
+
* 回,手机上看的就是这句)。无论成败都断开:留一个实例在中转上,下次真连接会被它顶一下。
|
|
34
|
+
*/
|
|
35
|
+
async function verifyRelayRegistration({ relayUrl, instanceId, upstreamPort, name, registerSecret }) {
|
|
36
|
+
// 地址形状不对在这里当场抛(与保存配置同一个判据),别等到 WebSocket 才炸。
|
|
37
|
+
const tunnel = new RelayTunnel({
|
|
38
|
+
relayUrl: core.tunnelWsUrl(relayUrl),
|
|
39
|
+
instanceId,
|
|
40
|
+
name: name || '',
|
|
41
|
+
upstream: `http://127.0.0.1:${upstreamPort}`,
|
|
42
|
+
registerSecret: typeof registerSecret === 'string' ? registerSecret : '',
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
await tunnel.start();
|
|
46
|
+
return { ok: true };
|
|
47
|
+
} finally {
|
|
48
|
+
try { tunnel.stop(); } catch { /* 已经断开 */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { probeInstanceId, verifyRelayRegistration, PROBE_MARK };
|