dsh-mobile-plugin 0.5.24 → 0.5.26
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 +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/impl.js +9 -0
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +90 -5
- package/lib/mobile/phone/tools.js +1190 -64
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +106 -0
- package/lib/p2p/session.cjs +455 -0
- package/lib/p2p/wiring.cjs +93 -0
- package/lib/panel/index.html +10 -10
- package/lib/relay/status.js +2 -0
- package/lib/routes/control-plane.js +24 -1
- package/lib/routes/mobile-api.js +20 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +37 -0
- package/package.json +4 -2
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 点对点隧道(P2P)的宿主侧会话:电脑这一半的身份、配对时换公钥、以及把它拉起来。
|
|
5
|
+
*
|
|
6
|
+
* 与直连隧道(`session.cjs`)刻意分成两个模块:那条要开会合窗口、要白名单、要区分
|
|
7
|
+
* 「中继会合 / 直连」两种路径;这条**没有中继、也没有窗口** —— 两端的公钥在配对时换一次,
|
|
8
|
+
* 之后就固定了,需要知道的只有「对方通到哪」(打洞候选)。混进同一个模块会让两边各有一半
|
|
9
|
+
* 用不上的字段,也容易把「打不通报 nat-blocked」和「中继没会合」说成同一件事。
|
|
10
|
+
*
|
|
11
|
+
* 三件必须一直成立:
|
|
12
|
+
* 1. 私钥落在插件自己的存储里(`relay.json`),助手不落盘 —— 插件永远是唯一真相。
|
|
13
|
+
* 2. 公钥可以明着给出去:配对二维码里那份、以及 `/connection-info` 报的都是公钥。
|
|
14
|
+
* 3. 打洞失败如实报,绝不改口成「已连通」:这条通道没有中继兜底,装不出来。
|
|
15
|
+
*
|
|
16
|
+
* 助手是可选依赖,插件加载时可能还没有。所以与直连隧道一样,只在入口上重探一次,
|
|
17
|
+
* 不做定时扫描。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { createTailcatHost, resolveBinary } = require('../tailcat/host-process.cjs');
|
|
21
|
+
|
|
22
|
+
/** 存进 relay.json 的四个键(与直连隧道那一组并列,互不影响)。 */
|
|
23
|
+
const STATE = {
|
|
24
|
+
enabled: 'p2pEnabled',
|
|
25
|
+
privateKey: 'p2pPrivateKey',
|
|
26
|
+
publicKey: 'p2pPublicKey',
|
|
27
|
+
/** 对端(手机)那把公钥:配对或会合时换进来的,拉起隧道时要拿它对握手。 */
|
|
28
|
+
peerKey: 'p2pPeerKey',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 电脑在**隧道里**听的那个端口(手机拨它)。与手机侧 `p2p.port` 同一个数。
|
|
33
|
+
*
|
|
34
|
+
* 它与「本机插件端口」是两件事:那个口是本机的,隧道里根本看不见。
|
|
35
|
+
*/
|
|
36
|
+
const P2P_PORT = 48950;
|
|
37
|
+
|
|
38
|
+
/** 把助手报上来的候选收成字符串数组(对端给的任何东西都当不可信数据)。 */
|
|
39
|
+
function candidateList(value) {
|
|
40
|
+
if (!Array.isArray(value)) return [];
|
|
41
|
+
return value.filter((item) => typeof item === 'string' && item.length > 0 && item.length <= 64).slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 等助手把隧道起来 / 报一次状态的上限。 */
|
|
45
|
+
const P2P_TIMEOUT_MS = 10_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} options
|
|
49
|
+
* @param {() => object} options.readState 读盘上的配置
|
|
50
|
+
* @param {(patch: object) => void} options.writeState 写回
|
|
51
|
+
* @param {number} options.localPort 插件本机端口(隧道里唯一的去处)
|
|
52
|
+
* @param {(message: string) => void} [options.log]
|
|
53
|
+
* @param {object} [options.host] 测试注入的助手句柄
|
|
54
|
+
* @param {() => (string|null)} [options.resolveBinary] 测试注入的查找函数
|
|
55
|
+
*/
|
|
56
|
+
function createP2PSession(options) {
|
|
57
|
+
const log = options.log || (() => {});
|
|
58
|
+
const findBinary = typeof options.resolveBinary === 'function' ? options.resolveBinary : resolveBinary;
|
|
59
|
+
let binary = options.host && !options.resolveBinary ? 'injected' : findBinary();
|
|
60
|
+
let host = null;
|
|
61
|
+
let lastError = '';
|
|
62
|
+
/** 最近一次报上来的隧道现状(打洞是异步的,这个随时可能变)。 */
|
|
63
|
+
let tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
|
|
64
|
+
/**
|
|
65
|
+
* 正在等助手回执的那些请求(**一组,不是一个**)。
|
|
66
|
+
*
|
|
67
|
+
* 早先这里只放得下「一个」,而会话起好之后状态轮询每 2 秒就要问一次现状、它也会占这个位。
|
|
68
|
+
* 后果很致命:手机的会合请求刚把等待位放进去,下一拍轮询就把它顶掉,于是助手回的那条
|
|
69
|
+
* `p2pReady` 交给了轮询、会合这一发永远等不到回执 —— 表现是「会合必超时、这条路永远接不上」,
|
|
70
|
+
* 而电脑那头一切正常。轮询与会合本来就会同时等着,两个都得能收到同一条事件。
|
|
71
|
+
*/
|
|
72
|
+
const waiters = new Set();
|
|
73
|
+
/** 最近一次配对 / 会合换进来的手机公钥。空串时会从盘上那份读回来。 */
|
|
74
|
+
let currentPeer = '';
|
|
75
|
+
/**
|
|
76
|
+
* 正在跑的那条隧道**配的是哪把手机公钥**(空串=还没起来)。
|
|
77
|
+
*
|
|
78
|
+
* 单独记一格而不是从 `currentPeer` 推:`currentPeer` 在会合一开始就被新来那台手机改写了,
|
|
79
|
+
* 而此刻隧道里跑的仍是上一台。拿它当判据就等于「已经换成新的了」,于是换设备时永不重开 ——
|
|
80
|
+
* 隧道里对端还是旧的,新手机发多少包都进不来。
|
|
81
|
+
*/
|
|
82
|
+
let runningPeer = '';
|
|
83
|
+
/** 状态轮询定时器与开关(见 `watch`)。 */
|
|
84
|
+
let timer = null;
|
|
85
|
+
let polling = false;
|
|
86
|
+
/** 这一进程里有没有真的被会合拉起过一条隧道(用来区分「还没人接」与「正在起」)。 */
|
|
87
|
+
let everOpened = false;
|
|
88
|
+
|
|
89
|
+
function snapshot() {
|
|
90
|
+
try {
|
|
91
|
+
const state = options.readState() || {};
|
|
92
|
+
return {
|
|
93
|
+
enabled: state[STATE.enabled] !== false,
|
|
94
|
+
privateKey: typeof state[STATE.privateKey] === 'string' ? state[STATE.privateKey] : '',
|
|
95
|
+
publicKey: typeof state[STATE.publicKey] === 'string' ? state[STATE.publicKey] : '',
|
|
96
|
+
// 对端公钥:进程重启之后 `currentPeer` 是空的,得从盘上那份接回来 —— 否则电脑会用
|
|
97
|
+
// 空的对端公钥去配设备,起不来,表现成「这条通道一直在启动中」。
|
|
98
|
+
peerKey: typeof state[STATE.peerKey] === 'string' ? state[STATE.peerKey] : '',
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return { enabled: true, privateKey: '', publicKey: '', peerKey: '' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function persist(patch) {
|
|
106
|
+
try {
|
|
107
|
+
options.writeState(patch);
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log(`P2P 隧道:写配置失败 ${error && error.message ? error.message : error}`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function settle(value) {
|
|
116
|
+
for (const done of [...waiters]) {
|
|
117
|
+
waiters.delete(done);
|
|
118
|
+
clearTimeout(done.timer);
|
|
119
|
+
done.resolve(value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 排一个等待者,超时自己摘掉(助手没答就回 null,别把内存漏下去)。 */
|
|
124
|
+
function await_(resolve) {
|
|
125
|
+
const done = {
|
|
126
|
+
resolve,
|
|
127
|
+
timer: setTimeout(() => {
|
|
128
|
+
waiters.delete(done);
|
|
129
|
+
resolve(null);
|
|
130
|
+
}, P2P_TIMEOUT_MS),
|
|
131
|
+
};
|
|
132
|
+
done.timer.unref?.();
|
|
133
|
+
waiters.add(done);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleEvent(event) {
|
|
137
|
+
switch (event.event) {
|
|
138
|
+
case 'p2pReady':
|
|
139
|
+
lastError = '';
|
|
140
|
+
tunnel = {
|
|
141
|
+
...tunnel,
|
|
142
|
+
running: true,
|
|
143
|
+
url: typeof event.url === 'string' ? event.url : '',
|
|
144
|
+
candidates: candidateList(event.candidates),
|
|
145
|
+
};
|
|
146
|
+
// 候选必须带出去:会合那一发要把它还给手机,否则手机不知道往哪打洞。
|
|
147
|
+
settle({ ok: true, url: tunnel.url, candidates: candidateList(event.candidates) });
|
|
148
|
+
break;
|
|
149
|
+
case 'p2pStatus':
|
|
150
|
+
tunnel = {
|
|
151
|
+
running: Boolean(event.running),
|
|
152
|
+
url: typeof event.url === 'string' ? event.url : '',
|
|
153
|
+
// 助手报的 path 有四种:direct / failed / punching(在打)/ waiting(等人来打)。
|
|
154
|
+
// 后两种都不是终态,但对界面来说「在打」与「在等」必须分得开。
|
|
155
|
+
path: event.path === 'direct' || event.path === 'failed'
|
|
156
|
+
|| event.path === 'punching' || event.path === 'waiting'
|
|
157
|
+
? event.path
|
|
158
|
+
: 'unknown',
|
|
159
|
+
active: event.active === true,
|
|
160
|
+
detail: typeof event.detail === 'string' ? event.detail : '',
|
|
161
|
+
candidates: candidateList(event.candidates),
|
|
162
|
+
};
|
|
163
|
+
settle({ ok: true, ...tunnel, candidates: candidateList(event.candidates) });
|
|
164
|
+
break;
|
|
165
|
+
case 'p2pKeys':
|
|
166
|
+
// 助手生成了密钥对:私钥存插件、公钥留给配对与状态用(公钥可以明着给出去)。
|
|
167
|
+
if (typeof event.private === 'string' && typeof event.public === 'string') {
|
|
168
|
+
persist({ [STATE.privateKey]: event.private, [STATE.publicKey]: event.public });
|
|
169
|
+
}
|
|
170
|
+
settle({ ok: true, publicKey: typeof event.public === 'string' ? event.public : '' });
|
|
171
|
+
break;
|
|
172
|
+
case 'p2pStopped':
|
|
173
|
+
tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
|
|
174
|
+
runningPeer = '';
|
|
175
|
+
settle({ ok: true, running: false });
|
|
176
|
+
break;
|
|
177
|
+
case 'error':
|
|
178
|
+
lastError = event.code;
|
|
179
|
+
settle({ ok: false, code: event.code });
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 只在入口上重探一次(与直连隧道同一条规矩:不做定时扫描)。 */
|
|
187
|
+
function locate() {
|
|
188
|
+
if (binary) return binary;
|
|
189
|
+
binary = findBinary();
|
|
190
|
+
if (binary) log(`P2P 隧道:找到助手 ${binary}`);
|
|
191
|
+
return binary;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function start() {
|
|
195
|
+
if (host) return host;
|
|
196
|
+
if (!locate()) {
|
|
197
|
+
lastError = 'helper-missing';
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (lastError === 'helper-missing') lastError = '';
|
|
201
|
+
const state = snapshot();
|
|
202
|
+
if (!state.enabled) return null;
|
|
203
|
+
host = options.host || createTailcatHost({
|
|
204
|
+
localPort: options.localPort,
|
|
205
|
+
state: null,
|
|
206
|
+
// P2P 没有白名单:能连上的前提是握手时用的那把私钥对得上,比白名单更直接。
|
|
207
|
+
allowedClients: [],
|
|
208
|
+
onEvent: handleEvent,
|
|
209
|
+
});
|
|
210
|
+
host.start();
|
|
211
|
+
// 还没生成过密钥对就先催一次。
|
|
212
|
+
//
|
|
213
|
+
// **不能只在配对那一刻催**:面板要显示「我这边的公钥」,手机要拿它配对端,而这两件事
|
|
214
|
+
// 都不该等到有人点开二维码才发生。更关键的是——不催就不会有 `p2pKeys` 事件,
|
|
215
|
+
// 也就没有公钥;面板上那格永远是空、状态永远是「正在启动」。早先只有 `exchange()`
|
|
216
|
+
// 里那一处催法,于是没开过码的机器上这条通道一直是死的。
|
|
217
|
+
if (!state.privateKey || !state.publicKey) {
|
|
218
|
+
if (typeof host.newKeysP2P === 'function') host.newKeysP2P();
|
|
219
|
+
}
|
|
220
|
+
return host;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stop() {
|
|
224
|
+
unwatch();
|
|
225
|
+
everOpened = false;
|
|
226
|
+
const current = host;
|
|
227
|
+
host = null;
|
|
228
|
+
tunnel = { running: false, url: '', path: 'unknown', active: false, detail: '', candidates: [] };
|
|
229
|
+
runningPeer = '';
|
|
230
|
+
if (current) return current.close();
|
|
231
|
+
return Promise.resolve();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 配对时换一次公钥:手机把自己的公钥报上来,同时拿走电脑这把。
|
|
236
|
+
*
|
|
237
|
+
* 电脑的密钥对在第一次被问到时生成一次,之后一直用同一对 —— 换一对等于所有已配对
|
|
238
|
+
* 手机都得重新配,那没有理由。
|
|
239
|
+
*/
|
|
240
|
+
function exchange(peerPublicKey) {
|
|
241
|
+
const state = snapshot();
|
|
242
|
+
if (!state.privateKey || !state.publicKey) {
|
|
243
|
+
// 还没生成过密钥对:这一步要等助手(Go 那边生成,它才懂 WireGuard 的密钥格式)。
|
|
244
|
+
// 入口上先**把助手拉起来**再催一次 —— 只 `if (host)` 的话,助手还没起来时这一催就是空的,
|
|
245
|
+
// 于是下次开码还是拿不到身份,用户看到的是「扫了码但没有这条通道」。
|
|
246
|
+
const live = start();
|
|
247
|
+
if (live && typeof live.newKeysP2P === 'function') live.newKeysP2P();
|
|
248
|
+
return { ok: false, error: 'no-identity', port: P2P_PORT };
|
|
249
|
+
}
|
|
250
|
+
if (typeof peerPublicKey === 'string' && peerPublicKey) {
|
|
251
|
+
persist({ [STATE.peerKey]: peerPublicKey });
|
|
252
|
+
currentPeer = peerPublicKey;
|
|
253
|
+
}
|
|
254
|
+
// 候选也要带出去:二维码里那一段是手机打洞的起点,空着就只能靠「只听不打」等握手。
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
publicKey: state.publicKey,
|
|
258
|
+
port: P2P_PORT,
|
|
259
|
+
peerKey: peerPublicKey,
|
|
260
|
+
candidates: tunnel.candidates || [],
|
|
261
|
+
localCandidates: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 面板与路由看到的现状。这里不重探,理由同直连隧道那条。 */
|
|
266
|
+
function status() {
|
|
267
|
+
const state = snapshot();
|
|
268
|
+
if (!binary) {
|
|
269
|
+
return {
|
|
270
|
+
available: false,
|
|
271
|
+
enabled: state.enabled,
|
|
272
|
+
ready: false,
|
|
273
|
+
started: false,
|
|
274
|
+
url: '',
|
|
275
|
+
path: 'unknown',
|
|
276
|
+
active: false,
|
|
277
|
+
error: 'helper-missing',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
available: true,
|
|
282
|
+
enabled: state.enabled,
|
|
283
|
+
ready: tunnel.running,
|
|
284
|
+
// 有没有被手机叫起来过。`ready:false` + `started:false` 说的不是「正在启动」,
|
|
285
|
+
// 而是「还没有手机来会合」—— 电脑这半边本来就是等人来打(「只听不打」)。
|
|
286
|
+
started: everOpened,
|
|
287
|
+
url: tunnel.url,
|
|
288
|
+
path: tunnel.path,
|
|
289
|
+
active: tunnel.active,
|
|
290
|
+
detail: tunnel.detail,
|
|
291
|
+
// 我自己的打洞候选也要给出去:手机那半边靠它知道往哪打。
|
|
292
|
+
candidates: tunnel.candidates || [],
|
|
293
|
+
publicKey: state.publicKey,
|
|
294
|
+
error: lastError,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 按手机给的公钥与候选把电脑侧那一半拉起来。
|
|
300
|
+
*
|
|
301
|
+
* 每次都先问一次现状:手机可能已经打通了(进程刚重启过、助手还在跑),那就直接复用,
|
|
302
|
+
* 不要重新起一条 —— 同一条通道起两条会让两边都拿不到稳定路径。
|
|
303
|
+
*/
|
|
304
|
+
/**
|
|
305
|
+
* 按手机给的候选把电脑侧那一半拉起来,并回手机要的那份(我自己这边的候选)。
|
|
306
|
+
*
|
|
307
|
+
* **幂等**:会合是手机每 60 秒会再来一次的心跳。候选没变、隧道又在跑,就直接把现状
|
|
308
|
+
* 回过去,**绝不重开** —— 重开等于把已经打通的洞每分钟踢掉一次,用户感受到的就是
|
|
309
|
+
* 「网一会儿通一会儿断」。
|
|
310
|
+
*/
|
|
311
|
+
function open(options = {}) {
|
|
312
|
+
everOpened = true;
|
|
313
|
+
const current = start();
|
|
314
|
+
if (!current) return Promise.resolve(null);
|
|
315
|
+
const state = snapshot();
|
|
316
|
+
if (!state.privateKey || !state.publicKey) return Promise.resolve(null);
|
|
317
|
+
|
|
318
|
+
// 手机把自己的公钥带过来了:存下来、当场用上。只有两端公钥互相对得上,WireGuard 才肯
|
|
319
|
+
// 完成握手 —— 拿不到手机那把,电脑配给设备的对端公钥要么是空的(起不来)、要么是上一台
|
|
320
|
+
// 手机的(握手永远失败,看着像「打洞打不通」)。
|
|
321
|
+
//
|
|
322
|
+
// **对端只认这一格**。手机发来的 `peerKey` 里装的是**电脑自己的公钥**(它拿它确认「对面
|
|
323
|
+
// 是那台电脑」),早先这里 `options.peerKey || currentPeer` 直接取前者,于是把电脑自己的
|
|
324
|
+
// 公钥配成了 WireGuard 对端 —— 电脑在跟自己握手。洞照样能开(UDP 打洞与密钥无关,所以
|
|
325
|
+
// `path` 会报 `direct`、面板一片绿),但数据面一个包也过不去,手机那条回环端口连上去
|
|
326
|
+
// 永远没有响应。这一格的名字与语义错位了一年,别再把它当对端用。
|
|
327
|
+
const phonePublicKey = typeof options.phonePublicKey === 'string' ? options.phonePublicKey.trim() : '';
|
|
328
|
+
if (phonePublicKey) {
|
|
329
|
+
if (phonePublicKey !== currentPeer) persist({ [STATE.peerKey]: phonePublicKey });
|
|
330
|
+
currentPeer = phonePublicKey;
|
|
331
|
+
}
|
|
332
|
+
const peerKey = String(phonePublicKey || currentPeer || snapshot().peerKey || '');
|
|
333
|
+
// 打一行现场:两端公钥对不对得上,是这条通道唯一真正致命的判据 ——
|
|
334
|
+
// 错了洞照样开、`path` 照样报 `direct`,但数据面一个包都过不去,从现象上看不出区别。
|
|
335
|
+
log(`P2P 隧道:会合 phonePublicKey=${phonePublicKey ? `${phonePublicKey.slice(0, 6)}…` : '(空)'}`
|
|
336
|
+
+ ` 采用的对端=${peerKey ? `${peerKey.slice(0, 6)}…` : '(空)'}`
|
|
337
|
+
+ ` 自己=${state.publicKey.slice(0, 6)}… 在跑=${tunnel.running} 在跑的对端=${runningPeer ? `${runningPeer.slice(0, 6)}…` : '(无)'}`);
|
|
338
|
+
const candidates = Array.isArray(options.candidates) ? options.candidates : [];
|
|
339
|
+
const localCandidates = Array.isArray(options.localCandidates) ? options.localCandidates : [];
|
|
340
|
+
|
|
341
|
+
// **已经按这把手机公钥起来的那条,才复用。**
|
|
342
|
+
//
|
|
343
|
+
// 早先写的是「隧道只要在跑就复用」,理由是候选变化不该踢掉已经打通的洞 —— 那一条仍然
|
|
344
|
+
// 成立(候选变化由分流器从来包自己学)。但「在跑」不足以说明「跑的是你」:这条通道一次
|
|
345
|
+
// 只服务一个对端,多台手机同时来会合时,谁先来隧道就归谁,后来那台每次会合都只拿到别人的
|
|
346
|
+
// 现状 —— 它自己的握手一个包都进不来,而电脑面板显示得好好地在跑。
|
|
347
|
+
// 所以判据收窄成「正在跑 + 跑的就是你这把公钥 + 你这把是真有」。
|
|
348
|
+
if (tunnel.running && peerKey && peerKey === runningPeer) {
|
|
349
|
+
return Promise.resolve({
|
|
350
|
+
ok: true,
|
|
351
|
+
running: true,
|
|
352
|
+
url: tunnel.url,
|
|
353
|
+
path: tunnel.path,
|
|
354
|
+
active: tunnel.active,
|
|
355
|
+
candidates: tunnel.candidates || [],
|
|
356
|
+
// 我这一把公钥:会合回包要把它带给手机(手机拿它配对端)。
|
|
357
|
+
publicKey: state.publicKey,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
// **另一台手机正在用,就如实说「被占用」,绝不去抢。**
|
|
361
|
+
//
|
|
362
|
+
// 这条通道一次只服务一个对端。早先这里是「换设备就重开」,本意是别让后来者永远排在旧
|
|
363
|
+
// 的后头 —— 但那条判据在多设备同时在线时变成互相抢:A 起来 → B 起来把 A 踢掉 → 下一轮
|
|
364
|
+
// A 又把 B 踢掉。每抢一次 socket 端口都换一个,两端手里的候选永远对不上,打洞永远打不穿,
|
|
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
|
+
}
|
|
378
|
+
|
|
379
|
+
return new Promise((resolve) => {
|
|
380
|
+
// 助手回的那几格(url / path / candidates)里没有公钥,在这里补上 —— 会合回包要用。
|
|
381
|
+
// **回调里顺手开轮询**:会合只等到「本机这半边起来了」,打洞结果要再问几次才有。
|
|
382
|
+
await_((reply) => {
|
|
383
|
+
watch();
|
|
384
|
+
resolve(reply && reply.ok ? { ...reply, publicKey: state.publicKey } : reply);
|
|
385
|
+
});
|
|
386
|
+
// 记下「这条隧道是按谁的公钥起来的」:下一次会合据此判「是不是同一台手机」。
|
|
387
|
+
runningPeer = peerKey;
|
|
388
|
+
current.startP2P({
|
|
389
|
+
privateKey: state.privateKey,
|
|
390
|
+
peerKey,
|
|
391
|
+
peerPublic: candidates,
|
|
392
|
+
peerLocal: localCandidates,
|
|
393
|
+
// 两个端口各司其职:listen 是手机在隧道里拨的,target 是本机插件在听的那个。
|
|
394
|
+
listenPort: P2P_PORT,
|
|
395
|
+
targetPort: Number(options.targetPort) || 0,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 问一次「打通了没有」。打洞是异步的,界面轮询这条。 */
|
|
401
|
+
function refresh() {
|
|
402
|
+
if (!host) return Promise.resolve(null);
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
await_(resolve);
|
|
405
|
+
host.statusP2P();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 定时问一次现状。
|
|
411
|
+
*
|
|
412
|
+
* **面板与 `/api/relay/p2p` 读的都是 `tunnel` 那一格,而它只会被助手的事件更新** ——
|
|
413
|
+
* 没人问,助手就不报;没人报,面板就永远停在「正在启动」。早先 `refresh()` 写好了却
|
|
414
|
+
* 没有任何调用点(路由与面板都没接),于是这条通道在界面上永远是「正在启动」,
|
|
415
|
+
* 哪怕它其实起得挺好。
|
|
416
|
+
*
|
|
417
|
+
* 只在**已经拉起过隧道**之后才问:没起过时不发,免得给一个必然超时的空转。
|
|
418
|
+
* `unref` 掉,别让这个定时器把进程钉住。
|
|
419
|
+
*/
|
|
420
|
+
function watch(intervalMs = 2000) {
|
|
421
|
+
if (polling) return;
|
|
422
|
+
polling = true;
|
|
423
|
+
const tick = () => {
|
|
424
|
+
if (!host) return;
|
|
425
|
+
void refresh();
|
|
426
|
+
};
|
|
427
|
+
timer = setInterval(tick, intervalMs);
|
|
428
|
+
timer.unref?.();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function unwatch() {
|
|
432
|
+
polling = false;
|
|
433
|
+
if (timer) {
|
|
434
|
+
clearInterval(timer);
|
|
435
|
+
timer = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
start,
|
|
441
|
+
stop,
|
|
442
|
+
status,
|
|
443
|
+
open,
|
|
444
|
+
refresh,
|
|
445
|
+
watch,
|
|
446
|
+
unwatch,
|
|
447
|
+
exchange,
|
|
448
|
+
/** 测试缝:直接喂一条助手事件(真实路径是子进程 stdout)。 */
|
|
449
|
+
_event: handleEvent,
|
|
450
|
+
constants: { P2P_PORT },
|
|
451
|
+
stateKeys: STATE,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
module.exports = { createP2PSession, STATE, P2P_PORT };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 把点对点隧道挂进装配层:只要一条助手会话,不要回环入口。
|
|
5
|
+
*
|
|
6
|
+
* 与直连隧道(`tailcat/wiring.cjs`)的差别就在这:那条要在本机开一个回环门、再把连接转给
|
|
7
|
+
* 局域网端口;这条的连接是**从隧道里进来的**(WireGuard 的虚拟网卡),助手自己转给
|
|
8
|
+
* `127.0.0.1:<插件端口>`,所以插件这边不需要任何监听。
|
|
9
|
+
*
|
|
10
|
+
* 任何一步起不来都只意味着「这台机器没有这条通道」:`mount` 兜住异常并留一行日志,别的
|
|
11
|
+
* 通道一行不受影响。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { createP2PSession, P2P_PORT } = require('./session.cjs');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {(label: string, fn: Function) => any} options.mount 装配层的兜底挂载
|
|
19
|
+
* @param {(message: string) => void} options.warn 告警出口
|
|
20
|
+
* @param {object} options.state relay.json 那份内存状态(唯一真值)
|
|
21
|
+
* @param {(state: object) => void} options.saveState 落盘
|
|
22
|
+
* @param {() => number} options.localPort 插件本机端口(隧道里唯一的去处)
|
|
23
|
+
* @param {object} options.ctx 插件 ctx(只为 `ctx.effect`)
|
|
24
|
+
* @param {string} options.name 插件名,用于 effect 标签
|
|
25
|
+
* @param {() => (string|null)} [options.resolveBinary] 测试缝:钉住「本机有没有助手」,
|
|
26
|
+
* 不让用例的结果取决于开发机上刚好有没有编译好的助手。
|
|
27
|
+
* @returns {object|undefined} 会话;起不来就是 undefined(路由与面板按「不可用」处理)
|
|
28
|
+
*/
|
|
29
|
+
function mountP2P({ mount, warn, state, saveState, localPort, ctx, name, resolveBinary }) {
|
|
30
|
+
/**
|
|
31
|
+
* 端口是异步拿到的(`lanPortInUse()` 依赖 sidebar 起来),所以会话也是异步建好的。
|
|
32
|
+
* 与直连隧道同一个做法:对外交**门面**,里面那格可能还是空,调用方问到的就是
|
|
33
|
+
* 「暂不可用」,等挂上自然就通。
|
|
34
|
+
*/
|
|
35
|
+
let session = null;
|
|
36
|
+
const door = {
|
|
37
|
+
status: () => (session
|
|
38
|
+
? session.status()
|
|
39
|
+
: { available: false, enabled: true, ready: false, url: '', path: 'unknown', active: false, error: 'starting' }),
|
|
40
|
+
exchange: peer => (session ? session.exchange(peer) : { ok: false, error: 'starting', port: 0 }),
|
|
41
|
+
open: options => (session ? session.open(options) : Promise.resolve(null)),
|
|
42
|
+
refresh: () => (session ? session.refresh() : Promise.resolve(null)),
|
|
43
|
+
// 起来之后开始定时问现状:不问,面板那一格永远是「正在启动」(见 session 的 watch)。
|
|
44
|
+
watch: () => (session ? session.watch() : undefined),
|
|
45
|
+
start: () => (session ? session.start() : null),
|
|
46
|
+
stop: () => (session ? session.stop() : Promise.resolve()),
|
|
47
|
+
// 会合回包要报「手机在隧道里拨哪个口」。会话还没建好时也得给得出来 —— 它是固定常量,
|
|
48
|
+
// 与会话在不在跑无关;漏了这一格,手机拿到空端口就判「这次没有这条通道」。
|
|
49
|
+
constants: { P2P_PORT },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// 启停单独一条命:跟 sidecar 不是一件事,谁挂了都不该带走对方。
|
|
53
|
+
mount('点对点隧道生命周期', () =>
|
|
54
|
+
ctx.effect(() => {
|
|
55
|
+
let closed = false;
|
|
56
|
+
Promise.resolve()
|
|
57
|
+
.then(() => {
|
|
58
|
+
if (closed) return;
|
|
59
|
+
const port = Number(localPort());
|
|
60
|
+
if (!Number.isInteger(port) || port <= 0) return;
|
|
61
|
+
session = createP2PSession({
|
|
62
|
+
readState: () => state,
|
|
63
|
+
writeState: patch => {
|
|
64
|
+
Object.assign(state, patch);
|
|
65
|
+
saveState(state);
|
|
66
|
+
},
|
|
67
|
+
localPort: port,
|
|
68
|
+
log: message => warn(message),
|
|
69
|
+
...(typeof resolveBinary === 'function' ? { resolveBinary } : {}),
|
|
70
|
+
});
|
|
71
|
+
session.start();
|
|
72
|
+
// 立刻开始问现状:面板读的是「助手报回来的那一格」,不问它就一直是空的。
|
|
73
|
+
session.watch();
|
|
74
|
+
})
|
|
75
|
+
.catch(error => warn(`点对点隧道没有起来:${error && error.message ? error.message : error}`));
|
|
76
|
+
return () => {
|
|
77
|
+
closed = true;
|
|
78
|
+
try {
|
|
79
|
+
if (session) {
|
|
80
|
+
session.unwatch();
|
|
81
|
+
void session.stop();
|
|
82
|
+
}
|
|
83
|
+
session = null;
|
|
84
|
+
} catch {
|
|
85
|
+
/* 已经在停 */
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}, `${name}: p2p`),
|
|
89
|
+
);
|
|
90
|
+
return door;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { mountP2P };
|