dsh-mobile-plugin 0.5.20 → 0.5.22
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机直连隧道的电脑端助手进程:找得到就拉起、挂了重拉、退出收掉。
|
|
5
|
+
*
|
|
6
|
+
* 这个文件只管「进程」:助手在哪、怎么起、stdout 上那几行事件是什么意思。业务判断
|
|
7
|
+
* (白名单该是谁、配对窗口什么时候开、状态存到哪)留给调用方——它通过 onEvent 收事件,
|
|
8
|
+
* 通过 `persist` 事件里的 id 回执告诉助手「这笔改动存住了没有」。助手自己不留状态,
|
|
9
|
+
* 所以插件永远只有一份真相。
|
|
10
|
+
*
|
|
11
|
+
* 二进制从按平台的 npm 包装出来(`dsh-mobile-tailcat-<平台>`),装不上就没有这条通道,
|
|
12
|
+
* 其余功能一点不受影响——这也是不做运行时下载的理由:走的还是插件自己那条包管理器链路,
|
|
13
|
+
* 国内镜像、完整性校验、断点重试都是包管理器现成的。
|
|
14
|
+
*
|
|
15
|
+
* 助手是 Go 编的原生程序,Windows 上是 .exe;它只监听回环(由插件告诉它本机端口),
|
|
16
|
+
* 隧道里也只开一个固定端口。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { spawn } = require('node:child_process');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
/** 助手 IPC 版本,跟 tailcat-host/main.go 的 protoVersion 一起改。 */
|
|
25
|
+
const IPC_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
/** 隧道里那个固定端口,与 tailcat-host/node.go 的 virtualPort 一致。 */
|
|
28
|
+
const TUNNEL_PORT = 48941;
|
|
29
|
+
|
|
30
|
+
/** 配对窗口那个端口,与 pairingPort 一致(手机侧知道它,但只需拨它一次)。 */
|
|
31
|
+
const PAIRING_PORT = 48942;
|
|
32
|
+
|
|
33
|
+
/** 一行 IPC 的上限,与助手的 lineLimit 一致。 */
|
|
34
|
+
const MAX_LINE = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
/** 重启退避:起不来时别把机器打满。 */
|
|
37
|
+
const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
38
|
+
|
|
39
|
+
/** 事件里允许出现的键,多一个少一个都当脏数据丢掉(stdout 是私有 IPC)。 */
|
|
40
|
+
const EVENT_KEYS = {
|
|
41
|
+
ready: ['address'],
|
|
42
|
+
pairing: ['address', 'port', 'key', 'proof', 'expiresAt'],
|
|
43
|
+
paired: ['address', 'client'],
|
|
44
|
+
pairingClosed: [],
|
|
45
|
+
persist: ['id', 'state', 'allowedClients'],
|
|
46
|
+
error: ['code'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ADDRESS = /^tc[\x21-\x7e]{0,4095}$/;
|
|
50
|
+
const PRIVATE_KEY = /^privkey:[0-9a-f]{64}$/;
|
|
51
|
+
const CLIENT_KEY = /^nodekey:[0-9a-f]{64}$/;
|
|
52
|
+
const PROOF = /^[A-Za-z0-9_-]{43}$/;
|
|
53
|
+
|
|
54
|
+
/** 这台机器对应哪个平台包(与 tools/build-tailcat-helper.mjs 的 id 一致)。 */
|
|
55
|
+
function platformId(platform = process.platform, arch = process.arch) {
|
|
56
|
+
const os = platform === 'win32' ? 'win32' : platform;
|
|
57
|
+
const cpu = arch === 'x64' ? 'x64' : arch === 'arm64' ? 'arm64' : arch;
|
|
58
|
+
const id = `${os}-${cpu}`;
|
|
59
|
+
return ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'win32-x64'].includes(id) ? id : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 找助手二进制:先按平台包解析(npm 装出来的正常形态),找不到就回 null——
|
|
64
|
+
* 调用方据此把这条通道标成「不可用」,不要抛错、不要影响插件启动。
|
|
65
|
+
*/
|
|
66
|
+
function resolveBinary(options = {}) {
|
|
67
|
+
const platform = options.platformId || platformId(options.platform, options.arch);
|
|
68
|
+
if (!platform) return null;
|
|
69
|
+
const name = platform === 'win32-x64' ? 'dsh-tailcat-host.exe' : 'dsh-tailcat-host';
|
|
70
|
+
const pkg = `dsh-mobile-tailcat-${platform}`;
|
|
71
|
+
|
|
72
|
+
// 1. 平台包(可选依赖装出来的那份)。npm / pnpm 把可选依赖装在「插件自己这份 node_modules」里,
|
|
73
|
+
// 所以先按本模块自身所在位置解析(从本文件往上找,嵌套与 hoisted 布局都命中)——运行时的工作
|
|
74
|
+
// 目录(宿主可能是别处)跟安装目录不是一回事,只按工作目录找会在真实部署里找不到;再退回调用方
|
|
75
|
+
// 指定路径 / 当前工作目录(老行为,部署方把包放在工作目录时仍可用)。
|
|
76
|
+
for (const paths of [undefined, options.paths || [process.cwd()]]) {
|
|
77
|
+
try {
|
|
78
|
+
const manifest = require.resolve(`${pkg}/package.json`, paths ? { paths } : undefined);
|
|
79
|
+
const candidate = path.join(path.dirname(manifest), name);
|
|
80
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
81
|
+
} catch {
|
|
82
|
+
/* 这一路没有:试下一路 */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. 指定的路径(开发机、或者部署方自己放好的那一份)。
|
|
87
|
+
const override = process.env.DSHM_TAILCAT_HELPER;
|
|
88
|
+
if (typeof override === 'string' && override && fs.existsSync(override)) return override;
|
|
89
|
+
|
|
90
|
+
// 3. 本仓交叉编译的产物(开发与自测用)。
|
|
91
|
+
const dev = path.join(__dirname, '..', '..', '..', 'build', 'tailcat-runtime', platform, name);
|
|
92
|
+
if (fs.existsSync(dev)) return dev;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 拉起一个助手进程。
|
|
98
|
+
*
|
|
99
|
+
* options:
|
|
100
|
+
* localPort 插件本机 HTTP 端口(隧道里唯一的去处)
|
|
101
|
+
* name 这台电脑的名字(手机配对时显示)
|
|
102
|
+
* state 上次存下来的身份(字符串)或 null
|
|
103
|
+
* allowedClients 已经配过对的手机公钥
|
|
104
|
+
* onEvent(event) 事件回调,event 已去掉 event 字段之外的脏数据
|
|
105
|
+
* resolve 覆盖二进制查找(测试用)
|
|
106
|
+
*/
|
|
107
|
+
function createTailcatHost(options) {
|
|
108
|
+
const binary = (options.resolve || resolveBinary)(options);
|
|
109
|
+
let child;
|
|
110
|
+
let buffer = '';
|
|
111
|
+
let closed = false;
|
|
112
|
+
let restarts = 0;
|
|
113
|
+
let backoffIndex = 0;
|
|
114
|
+
let restartTimer;
|
|
115
|
+
let ready = false;
|
|
116
|
+
let address = '';
|
|
117
|
+
|
|
118
|
+
const status = () => ({ available: Boolean(binary), running: Boolean(child), ready, address, restarts });
|
|
119
|
+
|
|
120
|
+
function write(value) {
|
|
121
|
+
if (!child || !child.stdin || !child.stdin.writable) return;
|
|
122
|
+
child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleLine(line) {
|
|
126
|
+
let value;
|
|
127
|
+
try {
|
|
128
|
+
value = JSON.parse(line);
|
|
129
|
+
} catch {
|
|
130
|
+
return; // 脏行直接丢,从不回显
|
|
131
|
+
}
|
|
132
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
|
133
|
+
const kind = value.event;
|
|
134
|
+
if (typeof kind !== 'string' || !EVENT_KEYS[kind]) return;
|
|
135
|
+
const keys = Object.keys(value);
|
|
136
|
+
const fields = EVENT_KEYS[kind];
|
|
137
|
+
if (keys.length !== fields.length + 1 || fields.some(field => !(field in value))) return;
|
|
138
|
+
|
|
139
|
+
if (kind === 'ready' && typeof value.address === 'string' && (value.address === '' || ADDRESS.test(value.address))) {
|
|
140
|
+
ready = true;
|
|
141
|
+
address = value.address;
|
|
142
|
+
backoffIndex = 0;
|
|
143
|
+
} else if (kind === 'pairing') {
|
|
144
|
+
if (!ADDRESS.test(String(value.address)) || value.port !== PAIRING_PORT) return;
|
|
145
|
+
if (!PRIVATE_KEY.test(String(value.key)) || !PROOF.test(String(value.proof))) return;
|
|
146
|
+
if (!Number.isSafeInteger(value.expiresAt)) return;
|
|
147
|
+
} else if (kind === 'paired') {
|
|
148
|
+
if (!CLIENT_KEY.test(String(value.client)) || !ADDRESS.test(String(value.address))) return;
|
|
149
|
+
address = value.address;
|
|
150
|
+
} else if (kind === 'persist') {
|
|
151
|
+
if (typeof value.id !== 'string' || value.id.length > 32) return;
|
|
152
|
+
if (!Array.isArray(value.allowedClients)) return;
|
|
153
|
+
} else if (kind === 'error') {
|
|
154
|
+
if (typeof value.code !== 'string' || value.code.length > 64) return;
|
|
155
|
+
ready = false;
|
|
156
|
+
}
|
|
157
|
+
options.onEvent({ ...value });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function scheduleRestart() {
|
|
161
|
+
if (closed || restartTimer) return;
|
|
162
|
+
const delay = BACKOFF_MS[Math.min(backoffIndex, BACKOFF_MS.length - 1)];
|
|
163
|
+
backoffIndex += 1;
|
|
164
|
+
restarts += 1;
|
|
165
|
+
restartTimer = setTimeout(() => {
|
|
166
|
+
restartTimer = undefined;
|
|
167
|
+
launch();
|
|
168
|
+
}, delay);
|
|
169
|
+
restartTimer.unref?.();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function launch() {
|
|
173
|
+
if (closed || !binary) return;
|
|
174
|
+
let proc;
|
|
175
|
+
try {
|
|
176
|
+
proc = spawn(binary, [], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true });
|
|
177
|
+
} catch {
|
|
178
|
+
scheduleRestart();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
child = proc;
|
|
182
|
+
buffer = '';
|
|
183
|
+
ready = false;
|
|
184
|
+
proc.on('error', () => {});
|
|
185
|
+
proc.stdin?.on('error', () => {});
|
|
186
|
+
proc.stdout?.setEncoding('utf8');
|
|
187
|
+
proc.stdout?.on('data', chunk => {
|
|
188
|
+
if (child !== proc) return;
|
|
189
|
+
buffer += chunk;
|
|
190
|
+
for (let at = buffer.indexOf('\n'); at >= 0; at = buffer.indexOf('\n')) {
|
|
191
|
+
const line = buffer.slice(0, at);
|
|
192
|
+
buffer = buffer.slice(at + 1);
|
|
193
|
+
handleLine(line);
|
|
194
|
+
}
|
|
195
|
+
if (buffer.length > MAX_LINE) proc.kill();
|
|
196
|
+
});
|
|
197
|
+
proc.on('exit', () => {
|
|
198
|
+
if (child !== proc) return;
|
|
199
|
+
child = undefined;
|
|
200
|
+
ready = false;
|
|
201
|
+
address = '';
|
|
202
|
+
if (!closed) scheduleRestart();
|
|
203
|
+
});
|
|
204
|
+
write({
|
|
205
|
+
version: IPC_VERSION,
|
|
206
|
+
localPort: options.localPort,
|
|
207
|
+
name: String(options.name || os.hostname()).slice(0, 64),
|
|
208
|
+
state: options.state === undefined ? null : options.state,
|
|
209
|
+
allowedClients: Array.isArray(options.allowedClients) ? options.allowedClients : [],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
status,
|
|
215
|
+
start() {
|
|
216
|
+
if (closed || child) return;
|
|
217
|
+
launch();
|
|
218
|
+
},
|
|
219
|
+
/** 开一个配对窗口(助手自己管两分钟过期)。 */
|
|
220
|
+
pair(ttlSeconds) {
|
|
221
|
+
write({ command: 'pair', ttl: ttlSeconds });
|
|
222
|
+
},
|
|
223
|
+
cancelPairing() {
|
|
224
|
+
write({ command: 'cancelPairing' });
|
|
225
|
+
},
|
|
226
|
+
/** 换白名单(吊销手机就走这条)。 */
|
|
227
|
+
setAllowed(allowedClients) {
|
|
228
|
+
write({ command: 'setAllowed', allowedClients: allowedClients || [] });
|
|
229
|
+
},
|
|
230
|
+
/** 回执:state 存住了没有。 */
|
|
231
|
+
ack(id, ok) {
|
|
232
|
+
write({ command: 'ack', id, ok: Boolean(ok) });
|
|
233
|
+
},
|
|
234
|
+
async close() {
|
|
235
|
+
if (closed) return;
|
|
236
|
+
closed = true;
|
|
237
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
238
|
+
restartTimer = undefined;
|
|
239
|
+
const proc = child;
|
|
240
|
+
child = undefined;
|
|
241
|
+
if (!proc || proc.exitCode !== null) return;
|
|
242
|
+
// 收起 stdin 就是助手的停止信号;它无视才强杀。
|
|
243
|
+
await new Promise(done => {
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
proc.kill();
|
|
246
|
+
done();
|
|
247
|
+
}, 3_000);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
proc.once('exit', () => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
done();
|
|
252
|
+
});
|
|
253
|
+
proc.stdin?.end();
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
IPC_VERSION,
|
|
261
|
+
TUNNEL_PORT,
|
|
262
|
+
PAIRING_PORT,
|
|
263
|
+
platformId,
|
|
264
|
+
resolveBinary,
|
|
265
|
+
createTailcatHost,
|
|
266
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 隧道和插件之间的那道小门:在回环上占一个固定的端口,进来的每条连接都转给「当前」的
|
|
5
|
+
* 局域网端口。
|
|
6
|
+
*
|
|
7
|
+
* 为什么中间要隔这一层:局域网端口是会变的(首选 18787 被占就让内核另分一个,重启后还
|
|
8
|
+
* 可能再变),而隧道那边把它当固定地址写死。直接告诉助手那个会变的端口,端口一变隧道
|
|
9
|
+
* 就整段指向一个没人听的地方,而手机侧只会看到「连上就断」。隔一层之后助手只认这个
|
|
10
|
+
* 回环端口,端口怎么变都由这里在每次连接时临时决定。
|
|
11
|
+
*
|
|
12
|
+
* 只监听 127.0.0.1,不接受任何外部来源;隧道进来的是本进程内的连接。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const net = require('node:net');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {() => number} options.targetPort 当前局域网端口(每次连接现读,允许变化)
|
|
20
|
+
* @param {(message: string) => void} [options.log]
|
|
21
|
+
*/
|
|
22
|
+
function createLoopbackIngress(options) {
|
|
23
|
+
const log = options.log || (() => {});
|
|
24
|
+
let server = null;
|
|
25
|
+
let port = 0;
|
|
26
|
+
const live = new Set();
|
|
27
|
+
|
|
28
|
+
function pipe(socket) {
|
|
29
|
+
const target = Number(options.targetPort());
|
|
30
|
+
if (!Number.isInteger(target) || target <= 0) {
|
|
31
|
+
socket.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const upstream = net.connect({ host: '127.0.0.1', port: target });
|
|
35
|
+
live.add(socket);
|
|
36
|
+
live.add(upstream);
|
|
37
|
+
const drop = () => {
|
|
38
|
+
live.delete(socket);
|
|
39
|
+
live.delete(upstream);
|
|
40
|
+
socket.destroy();
|
|
41
|
+
upstream.destroy();
|
|
42
|
+
};
|
|
43
|
+
socket.on('error', drop);
|
|
44
|
+
upstream.on('error', drop);
|
|
45
|
+
socket.on('close', drop);
|
|
46
|
+
upstream.on('close', drop);
|
|
47
|
+
socket.pipe(upstream);
|
|
48
|
+
upstream.pipe(socket);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let starting = null;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
/**
|
|
55
|
+
* 把回环端口开起来,拿到端口之后才算成;失败回 0。
|
|
56
|
+
*
|
|
57
|
+
* **必须是异步的**:`listen(0)` 之后 `address()` 要等 `listening` 事件才有值,同步读回来
|
|
58
|
+
* 恒是 null,端口就成了 0——隧道那边于是拿到一个「没有地址」的配置,整条通道静默不可用。
|
|
59
|
+
*/
|
|
60
|
+
start() {
|
|
61
|
+
if (starting) return starting;
|
|
62
|
+
server = net.createServer(pipe);
|
|
63
|
+
server.on('error', error => {
|
|
64
|
+
log(`隧道入口没有开起来:${error && error.message ? error.message : error}`);
|
|
65
|
+
});
|
|
66
|
+
starting = new Promise(resolve => {
|
|
67
|
+
try {
|
|
68
|
+
server.listen(0, '127.0.0.1', () => {
|
|
69
|
+
const address = server.address();
|
|
70
|
+
port = address && typeof address === 'object' ? address.port : 0;
|
|
71
|
+
resolve(port);
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log(`隧道入口监听失败:${error && error.message ? error.message : error}`);
|
|
75
|
+
resolve(0);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return starting;
|
|
79
|
+
},
|
|
80
|
+
port: () => port,
|
|
81
|
+
close() {
|
|
82
|
+
for (const socket of live) socket.destroy();
|
|
83
|
+
live.clear();
|
|
84
|
+
if (server) {
|
|
85
|
+
try {
|
|
86
|
+
server.close();
|
|
87
|
+
} catch {
|
|
88
|
+
/* already closed */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
server = null;
|
|
92
|
+
port = 0;
|
|
93
|
+
starting = null;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { createLoopbackIngress };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 直连隧道这一整套东西的宿主侧会话:拉起助手进程、把它的身份存进 relay.json、
|
|
5
|
+
* 开配对窗口、同步白名单、给面板一份状态。
|
|
6
|
+
*
|
|
7
|
+
* 只做「把助手和插件的存储接起来」,不掺 HTTP、不认识路由:路由那边拿 `status()` 和
|
|
8
|
+
* `openPairingWindow()` 就行,面板那边同理。这样它能被单测直接驱动(假助手进程即可)。
|
|
9
|
+
*
|
|
10
|
+
* 三件必须一直成立:
|
|
11
|
+
* 1. 存的是**助手自己的身份**(节点私钥 / 预共享密钥 / 区域),助手不落盘 —— 插件永远是
|
|
12
|
+
* 唯一真相,用户重置插件不会留下一把孤儿钥匙。
|
|
13
|
+
* 2. 配对窗口是一次性的:每次开窗口都换一把临时钥匙,换完(或过期)就作废。
|
|
14
|
+
* 3. 手机被吊销时白名单立刻变,助手那边整体重启节点;失败就把白名单退回原样,不能出现
|
|
15
|
+
* 「界面上已吊销、实际上还能连」。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { createTailcatHost, resolveBinary, TUNNEL_PORT, PAIRING_PORT } = require('./host-process.cjs');
|
|
19
|
+
|
|
20
|
+
/** 开一个配对窗口最多等多久(拉起一个节点要 1~3 秒,弱网更久一点)。 */
|
|
21
|
+
const PAIRING_WINDOW_TIMEOUT_MS = 20_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 让一台老设备进来时最多等多久(节点要整体重启一次)。
|
|
25
|
+
*
|
|
26
|
+
* 必须留在手机那一发的总预算之内(App 侧一次逻辑请求是 8 秒):等满 15 秒的话手机先超时,
|
|
27
|
+
* 界面上就变成「请求没送到电脑」,而电脑其实正在起节点。宁可早点认输 —— 手机 60 秒后会
|
|
28
|
+
* 自己再问一次,那时候节点已经起来了,第一发就能拿到地址。
|
|
29
|
+
*/
|
|
30
|
+
const ENROLL_TIMEOUT_MS = 6_000;
|
|
31
|
+
|
|
32
|
+
/** 状态里放白名单与服务端身份的那几格(键名)。 */
|
|
33
|
+
const STATE = {
|
|
34
|
+
enabled: 'tailcatEnabled',
|
|
35
|
+
identity: 'tailcatState',
|
|
36
|
+
allowed: 'tailcatAllowedClients',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {() => {enabled: boolean, identity: string|null, allowed: string[]}} options.readState 读盘上的隧道配置
|
|
42
|
+
* @param {(patch: object) => void} options.writeState 写回(只写这三个键)
|
|
43
|
+
* @param {number} options.localPort 插件本机端口,隧道里唯一的去处
|
|
44
|
+
* @param {() => string} [options.hostName] 电脑名(手机配对时显示)
|
|
45
|
+
* @param {(message: string) => void} [options.log] 诊断日志
|
|
46
|
+
* @param {object} [options.host] 测试注入的助手句柄
|
|
47
|
+
*/
|
|
48
|
+
function createTailcatSession(options) {
|
|
49
|
+
const log = options.log || (() => {});
|
|
50
|
+
const binary = options.host ? 'injected' : resolveBinary();
|
|
51
|
+
let host = null;
|
|
52
|
+
let lastError = '';
|
|
53
|
+
/** 正在等窗口的那次请求(一次只开一个窗口)。 */
|
|
54
|
+
let pendingWindow = null;
|
|
55
|
+
|
|
56
|
+
function snapshot() {
|
|
57
|
+
try {
|
|
58
|
+
const state = options.readState() || {};
|
|
59
|
+
return {
|
|
60
|
+
enabled: state[STATE.enabled] !== false,
|
|
61
|
+
identity: typeof state[STATE.identity] === 'string' ? state[STATE.identity] : null,
|
|
62
|
+
allowed: Array.isArray(state[STATE.allowed]) ? state[STATE.allowed] : [],
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return { enabled: true, identity: null, allowed: [] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function persist(patch) {
|
|
70
|
+
try {
|
|
71
|
+
options.writeState(patch);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log(`直连隧道:写配置失败 ${error && error.message ? error.message : error}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleEvent(event) {
|
|
80
|
+
switch (event.event) {
|
|
81
|
+
case 'ready':
|
|
82
|
+
lastError = '';
|
|
83
|
+
break;
|
|
84
|
+
case 'pairing':
|
|
85
|
+
if (pendingWindow) {
|
|
86
|
+
const done = pendingWindow;
|
|
87
|
+
pendingWindow = null;
|
|
88
|
+
done.resolve({
|
|
89
|
+
address: event.address,
|
|
90
|
+
port: PAIRING_PORT,
|
|
91
|
+
key: event.key,
|
|
92
|
+
proof: event.proof,
|
|
93
|
+
expiresAt: event.expiresAt,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case 'paired': {
|
|
98
|
+
// 助手已经把手机加进白名单;存下来,并让二维码作废(码里那把钥匙只服务这一次)。
|
|
99
|
+
const state = snapshot();
|
|
100
|
+
const allowed = state.allowed.includes(event.client) ? state.allowed : [...state.allowed, event.client];
|
|
101
|
+
persist({ [STATE.allowed]: allowed });
|
|
102
|
+
if (pendingWindowTimeout) clearTimeout(pendingWindowTimeout);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'persist': {
|
|
106
|
+
// 助手的身份(私钥 / 预共享密钥 / 区域)与白名单都落在这里;存住了才回执。
|
|
107
|
+
const patch = { [STATE.allowed]: event.allowedClients };
|
|
108
|
+
if (typeof event.state === 'string' && event.state) patch[STATE.identity] = event.state;
|
|
109
|
+
const ok = persist(patch);
|
|
110
|
+
host && host.ack(event.id, ok);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case 'pairingClosed':
|
|
114
|
+
if (pendingWindow) {
|
|
115
|
+
const done = pendingWindow;
|
|
116
|
+
pendingWindow = null;
|
|
117
|
+
done.resolve(null);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'error':
|
|
121
|
+
lastError = event.code;
|
|
122
|
+
if (pendingWindow) {
|
|
123
|
+
const done = pendingWindow;
|
|
124
|
+
pendingWindow = null;
|
|
125
|
+
done.resolve(null);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let pendingWindowTimeout = null;
|
|
134
|
+
|
|
135
|
+
function start() {
|
|
136
|
+
if (host) return;
|
|
137
|
+
if (!binary) {
|
|
138
|
+
lastError = 'helper-missing';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const state = snapshot();
|
|
142
|
+
if (!state.enabled) return;
|
|
143
|
+
host = options.host || createTailcatHost({
|
|
144
|
+
localPort: options.localPort,
|
|
145
|
+
name: typeof options.hostName === 'function' ? options.hostName() : undefined,
|
|
146
|
+
state: state.identity,
|
|
147
|
+
allowedClients: state.allowed,
|
|
148
|
+
onEvent: handleEvent,
|
|
149
|
+
});
|
|
150
|
+
host.start();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stop() {
|
|
154
|
+
if (pendingWindow) {
|
|
155
|
+
pendingWindow.resolve(null);
|
|
156
|
+
pendingWindow = null;
|
|
157
|
+
}
|
|
158
|
+
const current = host;
|
|
159
|
+
host = null;
|
|
160
|
+
if (current) return current.close();
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 面板与路由看到的现状。 */
|
|
165
|
+
function status() {
|
|
166
|
+
const state = snapshot();
|
|
167
|
+
if (!binary) return { available: false, enabled: state.enabled, ready: false, address: '', paired: state.allowed.length, error: 'helper-missing' };
|
|
168
|
+
const live = host && typeof host.status === 'function' ? host.status() : { running: false, ready: false, address: '' };
|
|
169
|
+
return {
|
|
170
|
+
available: true,
|
|
171
|
+
enabled: state.enabled,
|
|
172
|
+
running: Boolean(live.running),
|
|
173
|
+
ready: Boolean(live.ready),
|
|
174
|
+
address: live.address || '',
|
|
175
|
+
paired: state.allowed.length,
|
|
176
|
+
error: lastError,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 开一个配对窗口。成功给出发给手机的那三样(地址 / 一次性私钥 / 证明串);
|
|
182
|
+
* 拉不起节点或超时就给 null,调用方照「这次只能走原来的路子」处理。
|
|
183
|
+
*/
|
|
184
|
+
function openPairingWindow(ttlSeconds) {
|
|
185
|
+
if (!binary) return Promise.resolve(null);
|
|
186
|
+
start();
|
|
187
|
+
if (!host) return Promise.resolve(null);
|
|
188
|
+
if (pendingWindow) return pendingWindow.promise;
|
|
189
|
+
let resolveFn;
|
|
190
|
+
const promise = new Promise(resolve => {
|
|
191
|
+
resolveFn = resolve;
|
|
192
|
+
});
|
|
193
|
+
pendingWindow = { promise, resolve: resolveFn };
|
|
194
|
+
pendingWindowTimeout = setTimeout(() => {
|
|
195
|
+
if (!pendingWindow) return;
|
|
196
|
+
pendingWindow = null;
|
|
197
|
+
resolveFn(null);
|
|
198
|
+
}, PAIRING_WINDOW_TIMEOUT_MS);
|
|
199
|
+
pendingWindowTimeout.unref?.();
|
|
200
|
+
host.pair(ttlSeconds);
|
|
201
|
+
return promise;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 让一台**已经配过对**的手机进来,并等回它的入口地址。
|
|
206
|
+
*
|
|
207
|
+
* 这是「插件更新之后手机不用重新扫码」的那条路:手机手上已经有这台电脑的凭据,只是
|
|
208
|
+
* 还不知道有隧道这回事。它把自己的公钥发上来,我们加进白名单、把节点拉起来,再把它该拨
|
|
209
|
+
* 的地址回给它 —— 与当初「宿主改了中转地址、手机跟着换」是同一套机制。
|
|
210
|
+
*
|
|
211
|
+
* 白名单变了节点要整体重启(v0.7 没有单独加人的接口),所以这里要等一小段。
|
|
212
|
+
*/
|
|
213
|
+
async function enroll(publicKey) {
|
|
214
|
+
if (!binary || typeof publicKey !== 'string' || !publicKey.startsWith('nodekey:')) return null;
|
|
215
|
+
start();
|
|
216
|
+
if (!host) return null;
|
|
217
|
+
const state = snapshot();
|
|
218
|
+
if (!state.allowed.includes(publicKey)) {
|
|
219
|
+
if (!setAllowed([...state.allowed, publicKey])) return null;
|
|
220
|
+
}
|
|
221
|
+
const deadline = Date.now() + ENROLL_TIMEOUT_MS;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
const live = status();
|
|
224
|
+
if (live.address) return { address: live.address, port: TUNNEL_PORT };
|
|
225
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 换白名单(吊销手机、或者用户手动清空)。助手会整体重启节点。 */
|
|
231
|
+
function setAllowed(allowedClients) {
|
|
232
|
+
const allowed = Array.isArray(allowedClients) ? allowedClients : [];
|
|
233
|
+
if (!persist({ [STATE.allowed]: allowed })) return false;
|
|
234
|
+
host && host.setAllowed(allowed);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 面板上的总开关。关掉就是停进程、白名单留着(再打开不用重新配对)。 */
|
|
239
|
+
function setEnabled(enabled) {
|
|
240
|
+
const next = Boolean(enabled);
|
|
241
|
+
if (!persist({ [STATE.enabled]: next })) return false;
|
|
242
|
+
if (next) start();
|
|
243
|
+
else stop();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
start,
|
|
249
|
+
stop,
|
|
250
|
+
status,
|
|
251
|
+
enroll,
|
|
252
|
+
/** 测试缝:直接喂一条助手事件(真实路径是子进程 stdout)。 */
|
|
253
|
+
_event: handleEvent,
|
|
254
|
+
openPairingWindow,
|
|
255
|
+
setAllowed,
|
|
256
|
+
setEnabled,
|
|
257
|
+
/** 供测试与诊断:当前配置里的白名单。 */
|
|
258
|
+
allowedClients: () => snapshot().allowed,
|
|
259
|
+
constants: { TUNNEL_PORT, PAIRING_PORT },
|
|
260
|
+
stateKeys: STATE,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { createTailcatSession, STATE, TUNNEL_PORT, PAIRING_PORT };
|