dsh-mobile-plugin 0.5.26 → 0.5.27
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/impl.js +3 -3
- package/lib/p2p/rendezvous.cjs +13 -1
- package/lib/p2p/session.cjs +243 -103
- package/lib/p2p/wiring.cjs +26 -2
- package/lib/tailcat/host-process.cjs +17 -1
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -33,7 +33,7 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
|
|
|
33
33
|
const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
|
|
34
34
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
35
35
|
const { mountTailcat, chooseTailcatTarget } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
|
|
36
|
-
const { mountP2P } = require('./p2p/wiring.cjs') //
|
|
36
|
+
const { mountP2P, chooseP2PTarget } = require('./p2p/wiring.cjs') // 点对点隧道:只有助手会话(连接从隧道里进来,不必开回环门)+ 落点端口的选择
|
|
37
37
|
const { createPreviewService } = require('./relay/preview.js')
|
|
38
38
|
const { mountTerminal, mountCli } = require('./mobile/services.js')
|
|
39
39
|
// 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
|
|
@@ -306,8 +306,8 @@ function apply(ctx) {
|
|
|
306
306
|
const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
|
|
307
307
|
// 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
|
|
308
308
|
const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, targetPort: chooseTailcatTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), hostName: () => state.name, ctx, name })
|
|
309
|
-
//
|
|
310
|
-
const p2p = mountP2P({ mount, warn, state, saveState: core.saveState, localPort: lanPortInUse, ctx, name })
|
|
309
|
+
// 点对点隧道:两端打洞直连、数据不经过任何服务器。落点端口与直连隧道同一条判据(见 chooseP2PTarget)。
|
|
310
|
+
const p2p = mountP2P({ mount, warn, state, saveState: core.saveState, localPort: lanPortInUse, targetPort: chooseP2PTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), ctx, name })
|
|
311
311
|
// 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
|
|
312
312
|
// lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
|
|
313
313
|
// 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
|
package/lib/p2p/rendezvous.cjs
CHANGED
|
@@ -51,6 +51,18 @@ function normalizeCandidates(list) {
|
|
|
51
51
|
* @param {() => number} [deps.lanPortInUse] 本机插件端口(隧道里那些连接最终转给它)
|
|
52
52
|
*/
|
|
53
53
|
function createP2PRendezvousHandler(deps) {
|
|
54
|
+
/**
|
|
55
|
+
* 隧道里进来的连接该转到哪个本机端口。
|
|
56
|
+
*
|
|
57
|
+
* 会话门面自己知道(`lib/p2p/wiring.cjs` 的 `targetPort`:局域网开着转局域网端口、
|
|
58
|
+
* 关着转宿主 web 端口)。门面没给时才退回 `lanPortInUse` —— 那是老形状,局域网关着时
|
|
59
|
+
* 指向一个没人听的口,隧道会「连上了却毫无反应」。
|
|
60
|
+
*/
|
|
61
|
+
const targetPortOf = () => {
|
|
62
|
+
const door = deps.p2p;
|
|
63
|
+
if (door && typeof door.targetPort === 'function') return Number(door.targetPort()) || 0;
|
|
64
|
+
return typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : 0;
|
|
65
|
+
};
|
|
54
66
|
return async function handleRendezvous(_ctx, req, res) {
|
|
55
67
|
if (!deps.p2p || typeof deps.p2p.open !== 'function') {
|
|
56
68
|
// 这台电脑没装助手 / 没起这条通道:如实说,手机那边据此把这一条标成不可用,
|
|
@@ -76,7 +88,7 @@ function createP2PRendezvousHandler(deps) {
|
|
|
76
88
|
phonePublicKey,
|
|
77
89
|
candidates,
|
|
78
90
|
localCandidates,
|
|
79
|
-
targetPort:
|
|
91
|
+
targetPort: targetPortOf(),
|
|
80
92
|
});
|
|
81
93
|
if (!opened || opened.ok === false) {
|
|
82
94
|
sendJson(res, 200, {
|
package/lib/p2p/session.cjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* 之后就固定了,需要知道的只有「对方通到哪」(打洞候选)。混进同一个模块会让两边各有一半
|
|
9
9
|
* 用不上的字段,也容易把「打不通报 nat-blocked」和「中继没会合」说成同一件事。
|
|
10
10
|
*
|
|
11
|
+
* **一台电脑同时服务多台手机**:每把手机公钥各有一套状态(`peers` 那张表),助手那边每条
|
|
12
|
+
* 隧道也各是独立的 netstack,同一个隧道内端口在各自栈里互不冲突。所以这里不再有「谁先来
|
|
13
|
+
* 归谁、后来者回 busy」—— 那种退让是单条隧道时的权宜,多台共存之后它只会把第二台永远挡在
|
|
14
|
+
* 外面。
|
|
15
|
+
*
|
|
11
16
|
* 三件必须一直成立:
|
|
12
17
|
* 1. 私钥落在插件自己的存储里(`relay.json`),助手不落盘 —— 插件永远是唯一真相。
|
|
13
18
|
* 2. 公钥可以明着给出去:配对二维码里那份、以及 `/connection-info` 报的都是公钥。
|
|
@@ -31,7 +36,8 @@ const STATE = {
|
|
|
31
36
|
/**
|
|
32
37
|
* 电脑在**隧道里**听的那个端口(手机拨它)。与手机侧 `p2p.port` 同一个数。
|
|
33
38
|
*
|
|
34
|
-
*
|
|
39
|
+
* 它与「本机插件端口」是两件事:那个口是本机的,隧道里根本看不见。多台手机也是同一个
|
|
40
|
+
* 数 —— 每条隧道各有独立的用户态网络栈,同一个端口号在各自栈里互不冲突。
|
|
35
41
|
*/
|
|
36
42
|
const P2P_PORT = 48950;
|
|
37
43
|
|
|
@@ -41,6 +47,34 @@ function candidateList(value) {
|
|
|
41
47
|
return value.filter((item) => typeof item === 'string' && item.length > 0 && item.length <= 64).slice(0, 16);
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 一份候选的**规范化集合**:去重、排序。
|
|
52
|
+
*
|
|
53
|
+
* 用集合而不是列表比:手机每次报上来的顺序本来就不稳定(STUN 各家回的先后、网卡枚举的
|
|
54
|
+
* 先后都会变),按顺序比会把「一模一样的一份地址」判成变了,于是每轮会合都重开一次 ——
|
|
55
|
+
* 那等于每分钟把已经打通的洞踢掉一次。
|
|
56
|
+
*
|
|
57
|
+
* 公网与私网两格分开比、不合成一坨:助手拿它们做的不是一件事(公网那份是打洞目标,
|
|
58
|
+
* 私网那份是同一张网里的一跳),把「同一个地址从私网那格挪到公网那格」当成没变是错的。
|
|
59
|
+
*/
|
|
60
|
+
function candidateSet(value) {
|
|
61
|
+
return [...new Set(candidateList(value))].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sameCandidates(left, right) {
|
|
65
|
+
return left.public.length === right.public.length
|
|
66
|
+
&& left.public.every((item, index) => item === right.public[index])
|
|
67
|
+
&& left.local.length === right.local.length
|
|
68
|
+
&& left.local.every((item, index) => item === right.local[index]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 助手报的 path 有四种:direct / failed / punching(在打)/ waiting(等人来打)。 */
|
|
72
|
+
function normalizePath(value) {
|
|
73
|
+
return value === 'direct' || value === 'failed' || value === 'punching' || value === 'waiting'
|
|
74
|
+
? value
|
|
75
|
+
: 'unknown';
|
|
76
|
+
}
|
|
77
|
+
|
|
44
78
|
/** 等助手把隧道起来 / 报一次状态的上限。 */
|
|
45
79
|
const P2P_TIMEOUT_MS = 10_000;
|
|
46
80
|
|
|
@@ -59,8 +93,75 @@ function createP2PSession(options) {
|
|
|
59
93
|
let binary = options.host && !options.resolveBinary ? 'injected' : findBinary();
|
|
60
94
|
let host = null;
|
|
61
95
|
let lastError = '';
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
|
|
97
|
+
/** 一套「某台手机那条隧道」的状态。字段与 `status()` 的对外字段同名,别改。 */
|
|
98
|
+
function emptyTunnel() {
|
|
99
|
+
return {
|
|
100
|
+
running: false,
|
|
101
|
+
url: '',
|
|
102
|
+
path: 'unknown',
|
|
103
|
+
active: false,
|
|
104
|
+
detail: '',
|
|
105
|
+
candidates: [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 按手机公钥的那张表:每把公钥一套 `{ ...tunnel, runningCandidates, dialing, stamp }`。
|
|
111
|
+
*
|
|
112
|
+
* `runningCandidates` 单独记一格的理由与单条时代一样:「隧道在跑」不等于「跑的就是你现在
|
|
113
|
+
* 这份候选」。电脑第一次往往是在手机还没报出候选时以「只听不打」起来的;之后手机把真实
|
|
114
|
+
* 候选报上来,若只判公钥,就永远复用一个没有对端地址的隧道 —— 永远不往手机打,公网永远
|
|
115
|
+
* 不通(局域网能通只因为手机摸得到电脑的内网地址、先打过来了)。
|
|
116
|
+
*
|
|
117
|
+
* `dialing`:命令已经发给助手、还没收到回执。这中间再来一发会合必须认同一份,否则会连发
|
|
118
|
+
* 两条 `p2pStart`,两条各自开一次 socket、洞白开。
|
|
119
|
+
*
|
|
120
|
+
* `stamp`:最近一次被动到的顺序。`status()` 那几格老字段取「最近活跃的那条」,靠它排。
|
|
121
|
+
*
|
|
122
|
+
* 空串那把是**匿名槽**:老助手的事件里没有 `peerKey`、而这次会话又还没收到过任何手机
|
|
123
|
+
* 公钥时,事件落在它上面。它不进 `status().peers` 的详单。
|
|
124
|
+
*/
|
|
125
|
+
const peers = new Map();
|
|
126
|
+
let stampSeq = 0;
|
|
127
|
+
|
|
128
|
+
function ensurePeer(key) {
|
|
129
|
+
let entry = peers.get(key);
|
|
130
|
+
if (!entry) {
|
|
131
|
+
entry = { ...emptyTunnel(), runningCandidates: { public: [], local: [] }, dialing: false, stamp: 0 };
|
|
132
|
+
peers.set(key, entry);
|
|
133
|
+
}
|
|
134
|
+
entry.stamp = ++stampSeq;
|
|
135
|
+
return entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 最近活跃的那一条(没有就是 null)。 */
|
|
139
|
+
function recentPeer() {
|
|
140
|
+
let best = null;
|
|
141
|
+
for (const entry of peers.values()) {
|
|
142
|
+
if (!best || entry.stamp > best.stamp) best = entry;
|
|
143
|
+
}
|
|
144
|
+
return best;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 助手发来的事件属于哪一台手机。
|
|
149
|
+
*
|
|
150
|
+
* 新助手会带 `peerKey`(手机公钥),按它路由。**老助手不带**:退回「最近会合的那一台」;
|
|
151
|
+
* 连那一台都还没有、而表里恰好只有一把公钥时就用它;什么都没有就落进匿名槽。任何一条
|
|
152
|
+
* 退路都不抛错 —— 老助手配新插件必须照常能用。
|
|
153
|
+
*/
|
|
154
|
+
function routeKey(event) {
|
|
155
|
+
const announced = typeof event.peerKey === 'string' ? event.peerKey.trim() : '';
|
|
156
|
+
if (announced) {
|
|
157
|
+
lastPeer = announced;
|
|
158
|
+
return announced;
|
|
159
|
+
}
|
|
160
|
+
if (lastPeer) return lastPeer;
|
|
161
|
+
const named = [...peers.keys()].filter(key => key);
|
|
162
|
+
return named.length === 1 ? named[0] : '';
|
|
163
|
+
}
|
|
164
|
+
|
|
64
165
|
/**
|
|
65
166
|
* 正在等助手回执的那些请求(**一组,不是一个**)。
|
|
66
167
|
*
|
|
@@ -70,16 +171,8 @@ function createP2PSession(options) {
|
|
|
70
171
|
* 而电脑那头一切正常。轮询与会合本来就会同时等着,两个都得能收到同一条事件。
|
|
71
172
|
*/
|
|
72
173
|
const waiters = new Set();
|
|
73
|
-
/** 最近一次配对 /
|
|
74
|
-
let
|
|
75
|
-
/**
|
|
76
|
-
* 正在跑的那条隧道**配的是哪把手机公钥**(空串=还没起来)。
|
|
77
|
-
*
|
|
78
|
-
* 单独记一格而不是从 `currentPeer` 推:`currentPeer` 在会合一开始就被新来那台手机改写了,
|
|
79
|
-
* 而此刻隧道里跑的仍是上一台。拿它当判据就等于「已经换成新的了」,于是换设备时永不重开 ——
|
|
80
|
-
* 隧道里对端还是旧的,新手机发多少包都进不来。
|
|
81
|
-
*/
|
|
82
|
-
let runningPeer = '';
|
|
174
|
+
/** 最近一次配对 / 会合换进来的手机公钥(老助手事件没有 peerKey 时靠它兜底路由)。 */
|
|
175
|
+
let lastPeer = '';
|
|
83
176
|
/** 状态轮询定时器与开关(见 `watch`)。 */
|
|
84
177
|
let timer = null;
|
|
85
178
|
let polling = false;
|
|
@@ -93,8 +186,9 @@ function createP2PSession(options) {
|
|
|
93
186
|
enabled: state[STATE.enabled] !== false,
|
|
94
187
|
privateKey: typeof state[STATE.privateKey] === 'string' ? state[STATE.privateKey] : '',
|
|
95
188
|
publicKey: typeof state[STATE.publicKey] === 'string' ? state[STATE.publicKey] : '',
|
|
96
|
-
//
|
|
97
|
-
//
|
|
189
|
+
// 对端公钥:进程重启之后内存表是空的,得从盘上那份接回来 —— 否则电脑会用空的对端
|
|
190
|
+
// 公钥去配设备,起不来,表现成「这条通道一直在启动中」。盘上只存得下**最后一台**
|
|
191
|
+
// 会合过的手机,其余那几台等它们下一轮会合(60 秒)自然会补齐。
|
|
98
192
|
peerKey: typeof state[STATE.peerKey] === 'string' ? state[STATE.peerKey] : '',
|
|
99
193
|
};
|
|
100
194
|
} catch {
|
|
@@ -112,20 +206,40 @@ function createP2PSession(options) {
|
|
|
112
206
|
}
|
|
113
207
|
}
|
|
114
208
|
|
|
115
|
-
|
|
209
|
+
/**
|
|
210
|
+
* 唤醒等待者。
|
|
211
|
+
*
|
|
212
|
+
* 多台手机之后这里要按公钥分道:两台同时会合时,A 那条 `p2pReady` 绝不能拿去回 B ——
|
|
213
|
+
* 拿着别人的地址当自己的,B 会往一个自己够不到的 URL 上走。所以等待者按公钥认领,
|
|
214
|
+
* 只唤醒「等这一台的」与「不挑的」(轮询)。
|
|
215
|
+
*
|
|
216
|
+
* `key` 为空串是**认不出是哪台**的情况(老助手的事件里没有公钥):这时一律广播 ——
|
|
217
|
+
* 老助手一条进程只有一条隧道,它报的就是「有隧道起来了」,分道反而会把等着的那台饿死。
|
|
218
|
+
*/
|
|
219
|
+
function settle(value, key = '') {
|
|
116
220
|
for (const done of [...waiters]) {
|
|
221
|
+
if (key && done.key && done.key !== key) continue;
|
|
117
222
|
waiters.delete(done);
|
|
118
223
|
clearTimeout(done.timer);
|
|
119
224
|
done.resolve(value);
|
|
120
225
|
}
|
|
121
226
|
}
|
|
122
227
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
228
|
+
/**
|
|
229
|
+
* 排一个等待者,超时自己摘掉(助手没答就回 null,别把内存漏下去)。
|
|
230
|
+
*
|
|
231
|
+
* `key` 是这台手机的公钥(不挑就留空)。超时那一支还要顺手把这一格的「正在发起」清掉 ——
|
|
232
|
+
* 不清的话,那一格永远是 dialing,下一轮同候选的会合会被判成「已经在起,复用」,
|
|
233
|
+
* 于是这台就再也不会重新发起(看起来像「隧道卡住了、怎么等都不动」)。
|
|
234
|
+
*/
|
|
235
|
+
function await_(resolve, key = '') {
|
|
125
236
|
const done = {
|
|
237
|
+
key,
|
|
126
238
|
resolve,
|
|
127
239
|
timer: setTimeout(() => {
|
|
128
240
|
waiters.delete(done);
|
|
241
|
+
const entry = key ? peers.get(key) : null;
|
|
242
|
+
if (entry) entry.dialing = false;
|
|
129
243
|
resolve(null);
|
|
130
244
|
}, P2P_TIMEOUT_MS),
|
|
131
245
|
};
|
|
@@ -135,33 +249,31 @@ function createP2PSession(options) {
|
|
|
135
249
|
|
|
136
250
|
function handleEvent(event) {
|
|
137
251
|
switch (event.event) {
|
|
138
|
-
case 'p2pReady':
|
|
252
|
+
case 'p2pReady': {
|
|
139
253
|
lastError = '';
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
254
|
+
const key = routeKey(event);
|
|
255
|
+
const entry = ensurePeer(key);
|
|
256
|
+
entry.running = true;
|
|
257
|
+
entry.dialing = false;
|
|
258
|
+
entry.url = typeof event.url === 'string' ? event.url : '';
|
|
259
|
+
entry.candidates = candidateList(event.candidates);
|
|
146
260
|
// 候选必须带出去:会合那一发要把它还给手机,否则手机不知道往哪打洞。
|
|
147
|
-
settle({ ok: true, url:
|
|
261
|
+
settle({ ok: true, url: entry.url, candidates: entry.candidates }, key);
|
|
148
262
|
break;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
candidates: candidateList(event.candidates),
|
|
162
|
-
};
|
|
163
|
-
settle({ ok: true, ...tunnel, candidates: candidateList(event.candidates) });
|
|
263
|
+
}
|
|
264
|
+
case 'p2pStatus': {
|
|
265
|
+
const key = routeKey(event);
|
|
266
|
+
const entry = ensurePeer(key);
|
|
267
|
+
entry.running = Boolean(event.running);
|
|
268
|
+
entry.dialing = false;
|
|
269
|
+
entry.url = typeof event.url === 'string' ? event.url : '';
|
|
270
|
+
entry.path = normalizePath(event.path);
|
|
271
|
+
entry.active = event.active === true;
|
|
272
|
+
entry.detail = typeof event.detail === 'string' ? event.detail : '';
|
|
273
|
+
entry.candidates = candidateList(event.candidates);
|
|
274
|
+
settle({ ok: true, ...pick(entry), candidates: entry.candidates }, key);
|
|
164
275
|
break;
|
|
276
|
+
}
|
|
165
277
|
case 'p2pKeys':
|
|
166
278
|
// 助手生成了密钥对:私钥存插件、公钥留给配对与状态用(公钥可以明着给出去)。
|
|
167
279
|
if (typeof event.private === 'string' && typeof event.public === 'string') {
|
|
@@ -169,13 +281,23 @@ function createP2PSession(options) {
|
|
|
169
281
|
}
|
|
170
282
|
settle({ ok: true, publicKey: typeof event.public === 'string' ? event.public : '' });
|
|
171
283
|
break;
|
|
172
|
-
case 'p2pStopped':
|
|
173
|
-
|
|
174
|
-
|
|
284
|
+
case 'p2pStopped': {
|
|
285
|
+
const announced = typeof event.peerKey === 'string' ? event.peerKey.trim() : '';
|
|
286
|
+
if (announced) {
|
|
287
|
+
// 新助手:只收掉这一台那条,别人的照跑。
|
|
288
|
+
peers.delete(announced);
|
|
289
|
+
} else {
|
|
290
|
+
// 老助手:它一条进程只有一条隧道,回的就是「全停了」。
|
|
291
|
+
peers.clear();
|
|
292
|
+
}
|
|
175
293
|
settle({ ok: true, running: false });
|
|
176
294
|
break;
|
|
295
|
+
}
|
|
177
296
|
case 'error':
|
|
178
297
|
lastError = event.code;
|
|
298
|
+
// 助手报错意味着这一轮「正在发起」已经落空:不清这一位的话,那一格永远是 dialing,
|
|
299
|
+
// 下一轮同候选的会合会被判成「已经在起,复用」,这台就再也重新发不起来了。
|
|
300
|
+
for (const entry of peers.values()) entry.dialing = false;
|
|
179
301
|
settle({ ok: false, code: event.code });
|
|
180
302
|
break;
|
|
181
303
|
default:
|
|
@@ -183,6 +305,18 @@ function createP2PSession(options) {
|
|
|
183
305
|
}
|
|
184
306
|
}
|
|
185
307
|
|
|
308
|
+
/** 一条隧道里属于 `status()` 老字段的那几格(不带头里的记账字段)。 */
|
|
309
|
+
function pick(entry) {
|
|
310
|
+
return {
|
|
311
|
+
running: entry.running,
|
|
312
|
+
url: entry.url,
|
|
313
|
+
path: entry.path,
|
|
314
|
+
active: entry.active,
|
|
315
|
+
detail: entry.detail,
|
|
316
|
+
candidates: entry.candidates,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
186
320
|
/** 只在入口上重探一次(与直连隧道同一条规矩:不做定时扫描)。 */
|
|
187
321
|
function locate() {
|
|
188
322
|
if (binary) return binary;
|
|
@@ -223,10 +357,10 @@ function createP2PSession(options) {
|
|
|
223
357
|
function stop() {
|
|
224
358
|
unwatch();
|
|
225
359
|
everOpened = false;
|
|
360
|
+
lastPeer = '';
|
|
361
|
+
peers.clear();
|
|
226
362
|
const current = host;
|
|
227
363
|
host = null;
|
|
228
|
-
tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
|
|
229
|
-
runningPeer = '';
|
|
230
364
|
if (current) return current.close();
|
|
231
365
|
return Promise.resolve();
|
|
232
366
|
}
|
|
@@ -249,20 +383,26 @@ function createP2PSession(options) {
|
|
|
249
383
|
}
|
|
250
384
|
if (typeof peerPublicKey === 'string' && peerPublicKey) {
|
|
251
385
|
persist({ [STATE.peerKey]: peerPublicKey });
|
|
252
|
-
|
|
386
|
+
lastPeer = peerPublicKey;
|
|
253
387
|
}
|
|
254
388
|
// 候选也要带出去:二维码里那一段是手机打洞的起点,空着就只能靠「只听不打」等握手。
|
|
389
|
+
const entry = (lastPeer && peers.get(lastPeer)) || recentPeer();
|
|
255
390
|
return {
|
|
256
391
|
ok: true,
|
|
257
392
|
publicKey: state.publicKey,
|
|
258
393
|
port: P2P_PORT,
|
|
259
394
|
peerKey: peerPublicKey,
|
|
260
|
-
candidates:
|
|
395
|
+
candidates: entry ? entry.candidates || [] : [],
|
|
261
396
|
localCandidates: [],
|
|
262
397
|
};
|
|
263
398
|
}
|
|
264
399
|
|
|
265
|
-
/**
|
|
400
|
+
/**
|
|
401
|
+
* 面板与路由看到的现状。这里不重探,理由同直连隧道那条。
|
|
402
|
+
*
|
|
403
|
+
* **字段名一个都没动**(手机 `/status` 心跳读的就是它们)。多台手机之后语义是:
|
|
404
|
+
* 老字段取「最近活跃的那一条」,另外追加一份 `peers` 按手机公钥的详单给面板用。
|
|
405
|
+
*/
|
|
266
406
|
function status() {
|
|
267
407
|
const state = snapshot();
|
|
268
408
|
if (!binary) {
|
|
@@ -275,38 +415,45 @@ function createP2PSession(options) {
|
|
|
275
415
|
path: 'unknown',
|
|
276
416
|
active: false,
|
|
277
417
|
error: 'helper-missing',
|
|
418
|
+
peers: [],
|
|
278
419
|
};
|
|
279
420
|
}
|
|
421
|
+
const entry = recentPeer();
|
|
422
|
+
const current = entry ? pick(entry) : emptyTunnel();
|
|
423
|
+
// 详单:最近活跃的排前面。匿名槽(空串)不进这份 —— 它只是老助手事件的落点。
|
|
424
|
+
const roster = [...peers.entries()]
|
|
425
|
+
.filter(([key]) => key)
|
|
426
|
+
.sort((left, right) => right[1].stamp - left[1].stamp)
|
|
427
|
+
.map(([key, item]) => ({ peerKey: key, ...pick(item) }));
|
|
280
428
|
return {
|
|
281
429
|
available: true,
|
|
282
430
|
enabled: state.enabled,
|
|
283
|
-
ready:
|
|
431
|
+
ready: current.running,
|
|
284
432
|
// 有没有被手机叫起来过。`ready:false` + `started:false` 说的不是「正在启动」,
|
|
285
433
|
// 而是「还没有手机来会合」—— 电脑这半边本来就是等人来打(「只听不打」)。
|
|
286
434
|
started: everOpened,
|
|
287
|
-
url:
|
|
288
|
-
path:
|
|
289
|
-
active:
|
|
290
|
-
detail:
|
|
435
|
+
url: current.url,
|
|
436
|
+
path: current.path,
|
|
437
|
+
active: current.active,
|
|
438
|
+
detail: current.detail,
|
|
291
439
|
// 我自己的打洞候选也要给出去:手机那半边靠它知道往哪打。
|
|
292
|
-
candidates:
|
|
440
|
+
candidates: current.candidates || [],
|
|
293
441
|
publicKey: state.publicKey,
|
|
294
442
|
error: lastError,
|
|
443
|
+
// 追加字段(不是老字段的改名):每台手机那条隧道各是什么样,面板要能分开看。
|
|
444
|
+
peers: roster,
|
|
295
445
|
};
|
|
296
446
|
}
|
|
297
447
|
|
|
298
448
|
/**
|
|
299
|
-
*
|
|
449
|
+
* 按手机给的公钥与候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
|
|
300
450
|
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* 按手机给的候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
|
|
451
|
+
* **幂等**:会合是手机每 60 秒会再来一次的心跳。同一把手机公钥、同一份候选、**它那条**
|
|
452
|
+
* 隧道又在跑,就直接把现状回过去,**绝不重开** —— 重开等于把已经打通的洞每分钟踢掉一次,
|
|
453
|
+
* 用户感受到的就是「网一会儿通一会儿断」。候选**真的变了**才按新的重开。
|
|
306
454
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* 「网一会儿通一会儿断」。
|
|
455
|
+
* 判据从「全局唯一那个对端」变成「**这台手机自己那一份**」:多台共存之后,两台手机各判
|
|
456
|
+
* 各的,一台候选变了不会连累另一台。
|
|
310
457
|
*/
|
|
311
458
|
function open(options = {}) {
|
|
312
459
|
everOpened = true;
|
|
@@ -325,56 +472,46 @@ function createP2PSession(options) {
|
|
|
325
472
|
// `path` 会报 `direct`、面板一片绿),但数据面一个包也过不去,手机那条回环端口连上去
|
|
326
473
|
// 永远没有响应。这一格的名字与语义错位了一年,别再把它当对端用。
|
|
327
474
|
const phonePublicKey = typeof options.phonePublicKey === 'string' ? options.phonePublicKey.trim() : '';
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
const peerKey = String(phonePublicKey || currentPeer || snapshot().peerKey || '');
|
|
475
|
+
// 先只**算**出这次该用哪把对端公钥,**不落盘**:落盘要在复用判定之后 —— 顺序反过来的话,
|
|
476
|
+
// 一次会被判成复用的会合也会去改写盘上那份,而盘上只有一格(最后一台会合的手机)。
|
|
477
|
+
const peerKey = String(phonePublicKey || lastPeer || snapshot().peerKey || '');
|
|
333
478
|
// 打一行现场:两端公钥对不对得上,是这条通道唯一真正致命的判据 ——
|
|
334
479
|
// 错了洞照样开、`path` 照样报 `direct`,但数据面一个包都过不去,从现象上看不出区别。
|
|
480
|
+
const seen = peers.get(peerKey);
|
|
335
481
|
log(`P2P 隧道:会合 phonePublicKey=${phonePublicKey ? `${phonePublicKey.slice(0, 6)}…` : '(空)'}`
|
|
336
482
|
+ ` 采用的对端=${peerKey ? `${peerKey.slice(0, 6)}…` : '(空)'}`
|
|
337
|
-
+ ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${
|
|
483
|
+
+ ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${seen ? seen.running : false}`);
|
|
338
484
|
const candidates = Array.isArray(options.candidates) ? options.candidates : [];
|
|
339
485
|
const localCandidates = Array.isArray(options.localCandidates) ? options.localCandidates : [];
|
|
486
|
+
const wantedCandidates = { public: candidateSet(candidates), local: candidateSet(localCandidates) };
|
|
340
487
|
|
|
341
|
-
//
|
|
488
|
+
// **同一把手机公钥 + 同一份候选,才复用该手机自己那条。**
|
|
489
|
+
//
|
|
490
|
+
// 「在跑」不足以说明「跑的是你」:这条通道早先一次只服务一个对端,多台手机同时来会合
|
|
491
|
+
// 时,谁先来隧道就归谁 —— 后来那台每次会合都只拿到别人的现状。现在每把公钥各一套状态,
|
|
492
|
+
// 判据天然落在「它自己那份」上。
|
|
342
493
|
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (tunnel.running && peerKey && peerKey === runningPeer) {
|
|
494
|
+
// **候选也得一起判**:电脑第一次往往是在手机还没报出候选时以「只听不打」起来的,之后
|
|
495
|
+
// 手机把真实候选报上来;只判公钥就一直复用那个没有对端地址的隧道,电脑永远不往手机打。
|
|
496
|
+
// 局域网还好(手机摸得到内网地址、从那边打过来了),一到公网就只剩下「等待」。
|
|
497
|
+
if (peerKey && seen && (seen.running || seen.dialing)
|
|
498
|
+
&& sameCandidates(wantedCandidates, seen.runningCandidates)) {
|
|
349
499
|
return Promise.resolve({
|
|
350
500
|
ok: true,
|
|
351
501
|
running: true,
|
|
352
|
-
url:
|
|
353
|
-
path:
|
|
354
|
-
active:
|
|
355
|
-
candidates:
|
|
502
|
+
url: seen.url,
|
|
503
|
+
path: seen.path,
|
|
504
|
+
active: seen.active,
|
|
505
|
+
candidates: seen.candidates || [],
|
|
356
506
|
// 我这一把公钥:会合回包要把它带给手机(手机拿它配对端)。
|
|
357
507
|
publicKey: state.publicKey,
|
|
358
508
|
});
|
|
359
509
|
}
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// 面板上却老老实实报 `punching` / `failed`,看起来像 NAT 太严。真机上一台平板、一台手机
|
|
366
|
-
// 同时开着就是这个场面。
|
|
367
|
-
//
|
|
368
|
-
// 所以退回「谁先来归谁」,后来者拿到一句明确的不可用 + 原因,过一会儿再问(它那条定时
|
|
369
|
-
// 会合本来就在跑)。换手机的正确入口是让原来那台停下来(关 App / 换设备),不是抢。
|
|
370
|
-
if (tunnel.running && peerKey && peerKey !== runningPeer) {
|
|
371
|
-
log(`P2P 隧道:已有另一台手机在用(在跑的对端=${runningPeer.slice(0, 6)}…),这次不抢`);
|
|
372
|
-
return Promise.resolve({
|
|
373
|
-
ok: false,
|
|
374
|
-
code: 'busy',
|
|
375
|
-
publicKey: state.publicKey,
|
|
376
|
-
});
|
|
377
|
-
}
|
|
510
|
+
|
|
511
|
+
// 复用的门过了,才把对端公钥落盘。
|
|
512
|
+
if (peerKey && peerKey !== lastPeer) persist({ [STATE.peerKey]: peerKey });
|
|
513
|
+
if (peerKey) lastPeer = peerKey;
|
|
514
|
+
const entry = ensurePeer(peerKey);
|
|
378
515
|
|
|
379
516
|
return new Promise((resolve) => {
|
|
380
517
|
// 助手回的那几格(url / path / candidates)里没有公钥,在这里补上 —— 会合回包要用。
|
|
@@ -382,9 +519,12 @@ function createP2PSession(options) {
|
|
|
382
519
|
await_((reply) => {
|
|
383
520
|
watch();
|
|
384
521
|
resolve(reply && reply.ok ? { ...reply, publicKey: state.publicKey } : reply);
|
|
385
|
-
});
|
|
386
|
-
//
|
|
387
|
-
|
|
522
|
+
}, peerKey);
|
|
523
|
+
// 记下「这条隧道是按哪份候选起来的」:下一次会合据此判要不要重开。在这里记而不是等
|
|
524
|
+
// `p2pReady`:请求已经发给助手了,这中间再来一发会合必须认同一份,否则会连发两条
|
|
525
|
+
// `p2pStart`,两条各自开一次 socket、洞白开。
|
|
526
|
+
entry.dialing = true;
|
|
527
|
+
entry.runningCandidates = wantedCandidates;
|
|
388
528
|
current.startP2P({
|
|
389
529
|
privateKey: state.privateKey,
|
|
390
530
|
peerKey,
|
|
@@ -409,7 +549,7 @@ function createP2PSession(options) {
|
|
|
409
549
|
/**
|
|
410
550
|
* 定时问一次现状。
|
|
411
551
|
*
|
|
412
|
-
* **面板与 `/api/relay/p2p`
|
|
552
|
+
* **面板与 `/api/relay/p2p` 读的都是事件更新出来的那张表,而它只会被助手的事件更新** ——
|
|
413
553
|
* 没人问,助手就不报;没人报,面板就永远停在「正在启动」。早先 `refresh()` 写好了却
|
|
414
554
|
* 没有任何调用点(路由与面板都没接),于是这条通道在界面上永远是「正在启动」,
|
|
415
555
|
* 哪怕它其实起得挺好。
|
package/lib/p2p/wiring.cjs
CHANGED
|
@@ -7,12 +7,30 @@
|
|
|
7
7
|
* 局域网端口;这条的连接是**从隧道里进来的**(WireGuard 的虚拟网卡),助手自己转给
|
|
8
8
|
* `127.0.0.1:<插件端口>`,所以插件这边不需要任何监听。
|
|
9
9
|
*
|
|
10
|
+
* 隧道里进来的连接最终转给谁由 `chooseP2PTarget` 决定(局域网开着转局域网端口、关着转宿主
|
|
11
|
+
* web 端口),与会合那一发拿到的 `targetPort` 是同一个答案。
|
|
12
|
+
*
|
|
10
13
|
* 任何一步起不来都只意味着「这台机器没有这条通道」:`mount` 兜住异常并留一行日志,别的
|
|
11
14
|
* 通道一行不受影响。
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
const { createP2PSession, P2P_PORT } = require('./session.cjs');
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 隧道里进来的连接该转到哪个端口。
|
|
21
|
+
*
|
|
22
|
+
* 与直连隧道(`tailcat/wiring.cjs` 的 `chooseTailcatTarget`)同一个形状、同一个理由:
|
|
23
|
+
* 局域网开着就照旧转给「当前的局域网端口」,和一台 LAN 手机走完全同一条路;局域网关着
|
|
24
|
+
* 那个端口根本没人监听(`resolveLanPort` 直接早返回、sidecar 也不起),隧道会握手成功、
|
|
25
|
+
* 数据一到就断 —— 手机那边看到的是「连上了却毫无反应」。此时改转宿主自己的 web 端口,
|
|
26
|
+
* 手机 API 就在那里,局域网 sidecar 平时也只是把连接原样转给它。
|
|
27
|
+
*
|
|
28
|
+
* 端口是取值器而不是一个数:局域网开关与端口都会漂,隧道却未必每次会合都重建。
|
|
29
|
+
*/
|
|
30
|
+
function chooseP2PTarget({ lanEnabled, lanPort, webPort }) {
|
|
31
|
+
return () => (lanEnabled() ? lanPort() : webPort());
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* @param {object} options
|
|
18
36
|
* @param {(label: string, fn: Function) => any} options.mount 装配层的兜底挂载
|
|
@@ -20,13 +38,16 @@ const { createP2PSession, P2P_PORT } = require('./session.cjs');
|
|
|
20
38
|
* @param {object} options.state relay.json 那份内存状态(唯一真值)
|
|
21
39
|
* @param {(state: object) => void} options.saveState 落盘
|
|
22
40
|
* @param {() => number} options.localPort 插件本机端口(隧道里唯一的去处)
|
|
41
|
+
* @param {() => number} [options.targetPort] 隧道里进来的连接该转给谁;给了就用它,否则退回
|
|
42
|
+
* localPort(见 chooseP2PTarget)
|
|
23
43
|
* @param {object} options.ctx 插件 ctx(只为 `ctx.effect`)
|
|
24
44
|
* @param {string} options.name 插件名,用于 effect 标签
|
|
25
45
|
* @param {() => (string|null)} [options.resolveBinary] 测试缝:钉住「本机有没有助手」,
|
|
26
46
|
* 不让用例的结果取决于开发机上刚好有没有编译好的助手。
|
|
27
47
|
* @returns {object|undefined} 会话;起不来就是 undefined(路由与面板按「不可用」处理)
|
|
28
48
|
*/
|
|
29
|
-
function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolveBinary }) {
|
|
49
|
+
function mountP2P({ mount, warn, state, saveState, localPort, targetPort, ctx, name, resolveBinary }) {
|
|
50
|
+
const resolvedTargetPort = typeof targetPort === 'function' ? targetPort : localPort;
|
|
30
51
|
/**
|
|
31
52
|
* 端口是异步拿到的(`lanPortInUse()` 依赖 sidebar 起来),所以会话也是异步建好的。
|
|
32
53
|
* 与直连隧道同一个做法:对外交**门面**,里面那格可能还是空,调用方问到的就是
|
|
@@ -44,6 +65,9 @@ function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolve
|
|
|
44
65
|
watch: () => (session ? session.watch() : undefined),
|
|
45
66
|
start: () => (session ? session.start() : null),
|
|
46
67
|
stop: () => (session ? session.stop() : Promise.resolve()),
|
|
68
|
+
// 会合那一发要的「本机落点端口」:**每次现取**,不缓存。局域网开关与端口都会漂,
|
|
69
|
+
// 缓存一个数就会在关掉局域网之后继续往一个没人听的口转。
|
|
70
|
+
targetPort: () => Number(resolvedTargetPort()) || 0,
|
|
47
71
|
// 会合回包要报「手机在隧道里拨哪个口」。会话还没建好时也得给得出来 —— 它是固定常量,
|
|
48
72
|
// 与会话在不在跑无关;漏了这一格,手机拿到空端口就判「这次没有这条通道」。
|
|
49
73
|
constants: { P2P_PORT },
|
|
@@ -90,4 +114,4 @@ function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolve
|
|
|
90
114
|
return door;
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
module.exports = { mountP2P };
|
|
117
|
+
module.exports = { mountP2P, chooseP2PTarget };
|
|
@@ -50,6 +50,18 @@ const EVENT_KEYS = {
|
|
|
50
50
|
error: ['code'],
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 这些事件**允许**多带一个 `peerKey`(手机公钥),其余键一个不多。
|
|
55
|
+
*
|
|
56
|
+
* 一台电脑同时接多台手机之后,助手报的 `p2pReady` / `p2pStatus` 是「哪一台的」必须能分开,
|
|
57
|
+
* 否则插件没法把状态路由到对应那台。但这条 IPC 的严格白名单照旧:只给这两个事件开这一个
|
|
58
|
+
* 可选键,其余键、其余事件多一个字节都还是脏数据。老助手不发这一格,所以是可选而不是必填。
|
|
59
|
+
*/
|
|
60
|
+
const OPTIONAL_EVENT_KEYS = {
|
|
61
|
+
p2pReady: ['peerKey'],
|
|
62
|
+
p2pStatus: ['peerKey'],
|
|
63
|
+
};
|
|
64
|
+
|
|
53
65
|
const ADDRESS = /^tc[\x21-\x7e]{0,4095}$/;
|
|
54
66
|
const PRIVATE_KEY = /^privkey:[0-9a-f]{64}$/;
|
|
55
67
|
const CLIENT_KEY = /^nodekey:[0-9a-f]{64}$/;
|
|
@@ -138,7 +150,11 @@ function createTailcatHost(options) {
|
|
|
138
150
|
if (typeof kind !== 'string' || !EVENT_KEYS[kind]) return;
|
|
139
151
|
const keys = Object.keys(value);
|
|
140
152
|
const fields = EVENT_KEYS[kind];
|
|
141
|
-
|
|
153
|
+
// 必填键一个不许少;可选键(目前只有 `peerKey`)可以没有,但有了也必须是这一格。
|
|
154
|
+
if (fields.some(field => !(field in value))) return;
|
|
155
|
+
const optional = OPTIONAL_EVENT_KEYS[kind] || [];
|
|
156
|
+
const extra = keys.filter(key => key !== 'event' && !fields.includes(key) && !optional.includes(key));
|
|
157
|
+
if (extra.length > 0) return;
|
|
142
158
|
|
|
143
159
|
if (kind === 'ready' && typeof value.address === 'string' && (value.address === '' || ADDRESS.test(value.address))) {
|
|
144
160
|
ready = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.27",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"icon": "./icon.png",
|