cdp-tunnel 3.4.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extension-new/cdp/handler/special.js +12 -2
- package/extension-new/config-page-preview.html +5 -0
- package/extension-new/config-page.js +15 -4
- package/extension-new/core/connection-state.js +9 -0
- package/extension-new/core/websocket.js +8 -3
- package/extension-new/manifest.json +1 -1
- package/extension-new/utils/config.js +1 -1
- package/extension-new/utils/helpers.js +9 -3
- package/package.json +1 -1
- package/server/admin-console.html +481 -0
- package/server/modules/config.js +5 -0
- package/server/modules/port-pool.js +144 -21
- package/server/proxy-server.js +323 -4
- package/server/saas/auth.js +1 -1
- package/server/saas/key-manager.js +98 -0
|
@@ -24,9 +24,10 @@ class PortPoolManager {
|
|
|
24
24
|
this.idCounter = 0; // 全局唯一 id 计数器 // 现有 proxy 的引用(拿 plugin 连接)
|
|
25
25
|
this.createServers = []; // [http.Server] 每个 create 端口一个
|
|
26
26
|
this.createWss = []; // [WebSocket.Server] 每个 create 端口一个
|
|
27
|
-
this.portSessions = []; // [PortSession] 每个 create
|
|
27
|
+
this.portSessions = []; // [PortSession] 每个 create 端口一个(端口池端口用)
|
|
28
|
+
this.keySessions = new Map(); // apiKey → PortSession(主端口按 key 隔离,一 key 一 session)
|
|
29
|
+
this._keyToPortIndex = new Map(); // apiKey → portIndex(key 到端口池端口的映射)
|
|
28
30
|
this.takeoverServer = null;
|
|
29
|
-
this.takeoverWss = null;
|
|
30
31
|
this.targetToPort = new Map(); // targetId → portIndex(事件路由用)
|
|
31
32
|
this.sessionToPort = new Map(); // CDP sessionId → portIndex
|
|
32
33
|
}
|
|
@@ -51,16 +52,57 @@ class PortPoolManager {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
start() {
|
|
54
|
-
//
|
|
55
|
+
// 端口池第 0 个端口 = 主端口(9221),复用主 server 的 /client 入口(不另开 http.Server)
|
|
56
|
+
this._setupMainPortSession(0, CONFIG.PORT);
|
|
57
|
+
|
|
58
|
+
// 启动 create 端口(9231-9239),portIndex 从 1 开始(0 留给主端口)
|
|
55
59
|
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
|
|
56
60
|
const port = CONFIG.POOL_START + i;
|
|
57
|
-
this._startCreatePort(i, port);
|
|
61
|
+
this._startCreatePort(i + 1, port);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
// 启动 takeover 端口(9220)
|
|
61
65
|
this._startTakeoverPort();
|
|
62
66
|
|
|
63
|
-
console.log(`\n[PORT POOL] Started: takeover=${CONFIG.POOL_TAKEOVER_PORT}, create=${CONFIG.POOL_START}-${CONFIG.POOL_START + CONFIG.POOL_SIZE - 1}`);
|
|
67
|
+
console.log(`\n[PORT POOL] Started: takeover=${CONFIG.POOL_TAKEOVER_PORT}, create=${CONFIG.PORT}(main) + ${CONFIG.POOL_START}-${CONFIG.POOL_START + CONFIG.POOL_SIZE - 1}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 主端口(9221)作为端口池第 0 个端口:只建 PortSession,不开 http.Server。
|
|
72
|
+
* 9221 的 HTTP 和 upgrade 由主 proxy-server 处理,但走端口池的隔离逻辑。
|
|
73
|
+
*/
|
|
74
|
+
_setupMainPortSession(portIndex, port) {
|
|
75
|
+
const session = new PortPoolManager.PortSession(portIndex, port);
|
|
76
|
+
this.portSessions[portIndex] = session;
|
|
77
|
+
console.log(`[CREATE PORT ${portIndex}] Main port ${port} (reuses main server)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 按 key 获取/分配独立的端口池端口 session(一 key 一 session,完全隔离)
|
|
82
|
+
* 主端口(9221)的 /client 连接按 key 路由到端口池端口(9231+)的 session。
|
|
83
|
+
* 复用端口池端口的全部隔离逻辑(targetIds/事件路由/分组),不动 handlePluginMessage。
|
|
84
|
+
*
|
|
85
|
+
* @returns PortSession 或 null(无可用端口时)
|
|
86
|
+
*/
|
|
87
|
+
_getKeySession(apiKey) {
|
|
88
|
+
if (!this._keyToPortIndex) this._keyToPortIndex = new Map();
|
|
89
|
+
// 已分配过 → 直接返回
|
|
90
|
+
if (this._keyToPortIndex.has(apiKey)) {
|
|
91
|
+
return this.portSessions[this._keyToPortIndex.get(apiKey)];
|
|
92
|
+
}
|
|
93
|
+
// 找一个未分配的端口池端口(portIndex >= 1)
|
|
94
|
+
const usedIndexes = new Set(this._keyToPortIndex.values());
|
|
95
|
+
for (let i = 1; i < this.portSessions.length; i++) {
|
|
96
|
+
if (this.portSessions[i] && !usedIndexes.has(i)) {
|
|
97
|
+
this._keyToPortIndex.set(apiKey, i);
|
|
98
|
+
this.portSessions[i].apiKey = apiKey; // 标记这个 session 属于哪个 key
|
|
99
|
+
console.log(`[KEY SESSION] key=${apiKey.slice(0, 16)}... → portIndex=${i} (port ${this.portSessions[i].port})`);
|
|
100
|
+
return this.portSessions[i];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 端口池满(所有端口都已分配)→ 复用主端口 session(降级,不隔离)
|
|
104
|
+
console.warn(`[KEY SESSION] port pool exhausted, key=${apiKey.slice(0, 16)}... falling back to shared main session`);
|
|
105
|
+
return null;
|
|
64
106
|
}
|
|
65
107
|
|
|
66
108
|
_startCreatePort(portIndex, port) {
|
|
@@ -140,7 +182,7 @@ class PortPoolManager {
|
|
|
140
182
|
const url = new URL(req.url, `http://localhost:${session.port}`);
|
|
141
183
|
const path = url.pathname;
|
|
142
184
|
|
|
143
|
-
if (path === '/json/version') {
|
|
185
|
+
if (path === '/json/version' || path === '/json/version/') {
|
|
144
186
|
// 返回版本信息,webSocketDebuggerUrl 指向当前端口
|
|
145
187
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
146
188
|
res.end(JSON.stringify({
|
|
@@ -152,7 +194,7 @@ class PortPoolManager {
|
|
|
152
194
|
return;
|
|
153
195
|
}
|
|
154
196
|
|
|
155
|
-
if (path === '/json' || path === '/json/list') {
|
|
197
|
+
if (path === '/json' || path === '/json/' || path === '/json/list' || path === '/json/list/') {
|
|
156
198
|
// 返回这个端口创建的 tab 列表
|
|
157
199
|
// 从 session.targetIds 构建(这些是 createTarget 时记录的)
|
|
158
200
|
const targets = [];
|
|
@@ -184,11 +226,40 @@ class PortPoolManager {
|
|
|
184
226
|
* Client WebSocket 连接处理
|
|
185
227
|
*/
|
|
186
228
|
_handleClientConnect(ws, req, session) {
|
|
229
|
+
// 鉴权:从 URL 提取 key,找到对应的 plugin(一 key 一浏览器)
|
|
230
|
+
let apiKey = null;
|
|
231
|
+
try {
|
|
232
|
+
const url = new URL(req.url, `http://localhost:${session.port}`);
|
|
233
|
+
apiKey = url.searchParams.get('key') || null;
|
|
234
|
+
} catch {}
|
|
235
|
+
|
|
236
|
+
// 如果有 SaaS 鉴权(HAS_SAAS),校验 key
|
|
237
|
+
if (this.mainProxy.validateClientKey) {
|
|
238
|
+
const valid = this.mainProxy.validateClientKey(apiKey);
|
|
239
|
+
if (!valid) {
|
|
240
|
+
ws.close(4001, 'Invalid or missing API key');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ws.apiKey = apiKey; // 记录到 ws,后续命令转发时带上
|
|
246
|
+
|
|
247
|
+
// 主端口(portIndex === 0):按 key 路由到独立的端口池端口 session
|
|
248
|
+
// 每个 key 分配一个端口池端口(portSessions[1..N]),实现完全隔离
|
|
249
|
+
// - targetIds 独立(listtabs 只看自己的)
|
|
250
|
+
// - 事件路由用现有 targetToPort(按 portIndex,不动 handlePluginMessage)
|
|
251
|
+
// - 分组名按 key(阶段1已实现)
|
|
252
|
+
// 无 key 或端口池满时,fallback 到主端口共享 session(兼容)
|
|
253
|
+
if (session.portIndex === 0 && apiKey) {
|
|
254
|
+
const keySession = this._getKeySession(apiKey);
|
|
255
|
+
if (keySession) session = keySession;
|
|
256
|
+
}
|
|
257
|
+
|
|
187
258
|
session.clients.add(ws);
|
|
188
259
|
|
|
189
|
-
const pluginWs = this.mainProxy.getPluginConnection();
|
|
260
|
+
const pluginWs = this.mainProxy.getPluginConnection(apiKey);
|
|
190
261
|
if (!pluginWs) {
|
|
191
|
-
ws.close(1011, 'No extension connected');
|
|
262
|
+
ws.close(1011, apiKey ? 'No browser connected for this key' : 'No extension connected');
|
|
192
263
|
return;
|
|
193
264
|
}
|
|
194
265
|
|
|
@@ -209,12 +280,15 @@ class PortPoolManager {
|
|
|
209
280
|
|
|
210
281
|
// 通知扩展有 client 连接(带端口号作为分组标识)
|
|
211
282
|
// clientId 固定为 pool_{port},让扩展为每个端口建一个独立分组
|
|
283
|
+
// __groupName 带 key 名称,让扩展用 key 名称命名 Chrome 分组(一眼看出是谁的浏览器)
|
|
212
284
|
const poolClientId = `pool_${session.port}`;
|
|
285
|
+
pluginWs._lastPoolClientId = poolClientId;
|
|
213
286
|
pluginWs.send(JSON.stringify({
|
|
214
287
|
type: 'client-connected',
|
|
215
288
|
clientId: poolClientId,
|
|
216
289
|
__mode: 'create',
|
|
217
|
-
__connectionTag: String(session.port)
|
|
290
|
+
__connectionTag: String(session.port),
|
|
291
|
+
__groupName: pluginWs.apiKeyName || null
|
|
218
292
|
}));
|
|
219
293
|
|
|
220
294
|
// 合成输入命令需要 ensureVisible(和 forward.js 的逻辑一致)
|
|
@@ -357,13 +431,10 @@ class PortPoolManager {
|
|
|
357
431
|
const closedTid = pending.params.targetId;
|
|
358
432
|
session.targetIds.delete(closedTid);
|
|
359
433
|
session.targetUrls.delete(closedTid);
|
|
360
|
-
session.attachedTargets.delete(closedTid);
|
|
361
434
|
this.targetToPort.delete(closedTid);
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// 这里简单清理所有已关闭 target 的 session
|
|
366
|
-
}
|
|
435
|
+
// sessionId → client 映射在 targetCreated/attachedToTarget 时建立,
|
|
436
|
+
// 但端口池层不维护 sessionId ↔ targetId 反查表,故此处不清理 sessionToClient
|
|
437
|
+
// (session 级隔离下,client 断开时整体清理即可)
|
|
367
438
|
}
|
|
368
439
|
|
|
369
440
|
// 恢复原始 id,发给发起请求的 client
|
|
@@ -382,11 +453,36 @@ class PortPoolManager {
|
|
|
382
453
|
|
|
383
454
|
// 2. 事件消息(无 id):按 targetId 或 sessionId 路由
|
|
384
455
|
if (msg.method && msg.params) {
|
|
385
|
-
//
|
|
456
|
+
// 提取 targetId(attachedToTarget 的在 params.targetInfo.targetId)
|
|
386
457
|
let targetId = null;
|
|
387
458
|
if (msg.params.targetId) targetId = msg.params.targetId;
|
|
388
459
|
else if (msg.params.targetInfo && msg.params.targetInfo.targetId) targetId = msg.params.targetInfo.targetId;
|
|
389
460
|
|
|
461
|
+
// 关键:attachedToTarget 事件带 sessionId 时,必须注册 sessionToPort,
|
|
462
|
+
// 否则后续该 session 的 Page.*/Runtime.* 事件无法路由(Playwright evaluate 会卡死)。
|
|
463
|
+
// 这一步独立于 targetId 是否已知——auto-attach 场景下 targetId 可能尚未在 targetToPort 注册。
|
|
464
|
+
// 归属端口:优先用 targetId 查 targetToPort;查不到时,找唯一一个 pendingCreate 的端口,
|
|
465
|
+
// 再查不到则归到端口 0(POOL_SIZE=0 时只有端口 0;多端口时靠 createTarget 响应补注册 targetId→port)
|
|
466
|
+
if (msg.method === 'Target.attachedToTarget' && msg.params.sessionId) {
|
|
467
|
+
let portIdx = this.targetToPort.get(targetId);
|
|
468
|
+
if (portIdx === undefined) {
|
|
469
|
+
// 找 pendingCreate 的端口(createTarget 响应还没到,但事件先到了)
|
|
470
|
+
for (let pi = 0; pi < this.portSessions.length; pi++) {
|
|
471
|
+
if (this.portSessions[pi] && this.portSessions[pi].pendingCreate) { portIdx = pi; break; }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (portIdx === undefined) portIdx = 0; // 兜底:归到主端口
|
|
475
|
+
const sess = this.portSessions[portIdx];
|
|
476
|
+
if (sess) {
|
|
477
|
+
if (targetId) {
|
|
478
|
+
sess.targetIds.add(targetId);
|
|
479
|
+
this.targetToPort.set(targetId, portIdx);
|
|
480
|
+
}
|
|
481
|
+
this.sessionToPort.set(msg.params.sessionId, portIdx);
|
|
482
|
+
sess.sessionToClient.delete(msg.params.sessionId); // auto-attach:广播
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
390
486
|
if (targetId) {
|
|
391
487
|
const portIndex = this.targetToPort.get(targetId);
|
|
392
488
|
if (portIndex !== undefined) {
|
|
@@ -406,13 +502,37 @@ class PortPoolManager {
|
|
|
406
502
|
sess.targetUrls.set(targetId, 'about:blank');
|
|
407
503
|
this.targetToPort.set(targetId, pi);
|
|
408
504
|
if (msg.method === 'Target.attachedToTarget') sess.pendingCreate = false;
|
|
505
|
+
// attachedToTarget 事件带 sessionId:必须注册 sessionToPort/sessionToClient,
|
|
506
|
+
// 否则后续 Page.*/Runtime.* 等 session 事件无法路由(Playwright newPage 会卡死)
|
|
507
|
+
if (msg.method === 'Target.attachedToTarget' && msg.params && msg.params.sessionId) {
|
|
508
|
+
this.sessionToPort.set(msg.params.sessionId, pi);
|
|
509
|
+
// auto-attach 场景无特定发起 client,广播给该端口所有 client
|
|
510
|
+
sess.sessionToClient.delete(msg.params.sessionId);
|
|
511
|
+
}
|
|
409
512
|
this._broadcastToPort(pi, msg);
|
|
410
513
|
return true;
|
|
411
514
|
}
|
|
412
515
|
}
|
|
413
|
-
// pendingCreate
|
|
414
|
-
//
|
|
415
|
-
|
|
516
|
+
// pendingCreate 没匹配到——仍需处理 setAutoAttach 自动触发的 attachedToTarget
|
|
517
|
+
// (Playwright/Puppeteer 的 newPage 走的就是这条路径,不走 createTarget lock)
|
|
518
|
+
// 用 targetToPort 找到归属端口;若 targetId 也未知,广播给所有端口(保守)
|
|
519
|
+
if (msg.method === 'Target.attachedToTarget' && msg.params && msg.params.sessionId) {
|
|
520
|
+
const knownPort = this.targetToPort.get(targetId);
|
|
521
|
+
const portIdx = knownPort !== undefined ? knownPort : 0;
|
|
522
|
+
const sess = this.portSessions[portIdx];
|
|
523
|
+
if (sess) {
|
|
524
|
+
sess.targetIds.add(targetId);
|
|
525
|
+
this.targetToPort.set(targetId, portIdx);
|
|
526
|
+
this.sessionToPort.set(msg.params.sessionId, portIdx);
|
|
527
|
+
sess.sessionToClient.delete(msg.params.sessionId); // auto-attach:广播
|
|
528
|
+
this._broadcastToPort(portIdx, msg);
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
// targetCreated 无 pendingCreate 且非 attachedToTarget:记录但不广播
|
|
533
|
+
if (msg.method === 'Target.targetCreated') {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
416
536
|
}
|
|
417
537
|
}
|
|
418
538
|
|
|
@@ -422,10 +542,13 @@ class PortPoolManager {
|
|
|
422
542
|
if (portIndex !== undefined) {
|
|
423
543
|
const sess = this.portSessions[portIndex];
|
|
424
544
|
if (sess) {
|
|
425
|
-
//
|
|
545
|
+
// 精确路由:有特定 owner 就单播,否则广播给该端口所有 client
|
|
546
|
+
// (auto-attach 场景无 owner,Playwright/Puppeteer 需要 Page.* 事件)
|
|
426
547
|
const ownerWs = sess.sessionToClient.get(msg.sessionId);
|
|
427
548
|
if (ownerWs && ownerWs.readyState === WebSocket.OPEN) {
|
|
428
549
|
ownerWs.send(JSON.stringify(msg));
|
|
550
|
+
} else {
|
|
551
|
+
this._broadcastToPort(portIndex, msg);
|
|
429
552
|
}
|
|
430
553
|
return true;
|
|
431
554
|
}
|
package/server/proxy-server.js
CHANGED
|
@@ -23,8 +23,11 @@ const TAKEOVER_PORT = CONFIG.TAKEOVER_PORT;
|
|
|
23
23
|
let portPool = null;
|
|
24
24
|
const { logCDP, logEvent, clearLog, logStatus, logConnectionEvent, flushAllLogs, logDisconnect } = require('./modules/logger');
|
|
25
25
|
|
|
26
|
+
let validateApiKey = null;
|
|
27
|
+
let saasAuth = null; // 完整 SaaS auth 模块(createApiKey/listApiKeys/revokeApiKey)
|
|
26
28
|
try {
|
|
27
|
-
|
|
29
|
+
saasAuth = require('./saas/auth');
|
|
30
|
+
validateApiKey = saasAuth.validateApiKey;
|
|
28
31
|
var HAS_SAAS = true;
|
|
29
32
|
} catch (e) {
|
|
30
33
|
var HAS_SAAS = false;
|
|
@@ -317,9 +320,280 @@ function resolvePluginFromUrl(url) {
|
|
|
317
320
|
return pluginConnections.values().next().value || null;
|
|
318
321
|
}
|
|
319
322
|
|
|
323
|
+
// ===== 管理控制台 API =====
|
|
324
|
+
// 鉴权:ADMIN_TOKEN 环境变量(Bearer token 或 query ?token=)
|
|
325
|
+
// 未设 ADMIN_TOKEN 时只允许 localhost(开发友好)
|
|
326
|
+
function checkAdminAuth(req, url) {
|
|
327
|
+
const token = process.env.ADMIN_TOKEN;
|
|
328
|
+
if (!token) {
|
|
329
|
+
// 未设 token:只允许 localhost
|
|
330
|
+
const ip = req.socket.remoteAddress;
|
|
331
|
+
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
332
|
+
}
|
|
333
|
+
// 设了 token:校验 Bearer 或 query
|
|
334
|
+
const authHeader = req.headers.authorization || '';
|
|
335
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
336
|
+
const queryToken = url.searchParams.get('token');
|
|
337
|
+
return bearer === token || queryToken === token;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function adminJson(res, code, data) {
|
|
341
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
342
|
+
res.end(JSON.stringify(data));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function readBody(req) {
|
|
346
|
+
return new Promise(resolve => {
|
|
347
|
+
let d = ''; req.on('data', c => d += c);
|
|
348
|
+
req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function handleAdminApi(req, res, url) {
|
|
353
|
+
const path = url.pathname.replace('/admin/api/', '');
|
|
354
|
+
const method = req.method;
|
|
355
|
+
|
|
356
|
+
if (!checkAdminAuth(req, url)) {
|
|
357
|
+
return adminJson(res, 401, { error: 'Unauthorized. Set ADMIN_TOKEN env or access from localhost.' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// 浏览器列表
|
|
362
|
+
if (path === 'browsers' && method === 'GET') {
|
|
363
|
+
const browsers = [];
|
|
364
|
+
for (const pluginWs of pluginConnections) {
|
|
365
|
+
if (pluginWs.readyState !== WebSocket.OPEN) continue;
|
|
366
|
+
const ns = getNamespace(pluginWs);
|
|
367
|
+
browsers.push({
|
|
368
|
+
pluginId: pluginWs.pluginId,
|
|
369
|
+
pluginName: pluginWs.pluginName || 'My Browser',
|
|
370
|
+
userId: pluginWs.userId || null,
|
|
371
|
+
apiKeyId: pluginWs.apiKeyId || null,
|
|
372
|
+
browserName: ns.cachedBrowserVersion?.Browser || 'Unknown',
|
|
373
|
+
userAgent: ns.cachedBrowserVersion?.['User-Agent'] || '',
|
|
374
|
+
targets: ns.cachedTargets?.length || 0,
|
|
375
|
+
connected: true,
|
|
376
|
+
connectedAt: pluginWs.connectedAt,
|
|
377
|
+
extVersion: pluginWs.extVersion || 'unknown'
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return adminJson(res, 200, browsers);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// key 管理(需要 SaaS)
|
|
384
|
+
if (path === 'keys' && method === 'GET') {
|
|
385
|
+
if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
|
|
386
|
+
return adminJson(res, 200, saasAuth.listApiKeys('builtin-admin'));
|
|
387
|
+
}
|
|
388
|
+
if (path === 'keys' && method === 'POST') {
|
|
389
|
+
if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
|
|
390
|
+
const body = await readBody(req);
|
|
391
|
+
const name = body.name || ('browser-' + Date.now().toString(36));
|
|
392
|
+
const result = saasAuth.createApiKey('builtin-admin', name);
|
|
393
|
+
const port = PORT;
|
|
394
|
+
return adminJson(res, 200, {
|
|
395
|
+
...result,
|
|
396
|
+
pluginUrl: `ws://localhost:${port}/plugin?key=${result.key}`,
|
|
397
|
+
clientUrl: `ws://localhost:${port}/client?key=${result.key}`
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (path.startsWith('keys/') && method === 'DELETE') {
|
|
401
|
+
if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
|
|
402
|
+
const keyId = path.split('/')[1];
|
|
403
|
+
const result = saasAuth.revokeApiKey(keyId, 'builtin-admin');
|
|
404
|
+
return adminJson(res, result.changes > 0 ? 200 : 404, { ok: result.changes > 0, changes: result.changes });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// CDP 快捷操作(通过 pluginId 直接连已在线浏览器)
|
|
408
|
+
if (path.startsWith('cdp/') && method === 'POST') {
|
|
409
|
+
const body = await readBody(req);
|
|
410
|
+
if (!body.pluginId) return adminJson(res, 400, { error: 'Missing pluginId' });
|
|
411
|
+
const result = await execCdpViaPlugin(body.pluginId, body);
|
|
412
|
+
return adminJson(res, result.error ? 500 : 200, result);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return adminJson(res, 404, { error: 'Not found: ' + path });
|
|
416
|
+
} catch (e) {
|
|
417
|
+
return adminJson(res, 500, { error: e.message });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 通过 pluginId 走端口池 /client 路径执行 CDP 命令(复用完整分组/session 路由逻辑)
|
|
422
|
+
async function execCdpViaPlugin(pluginId, params) {
|
|
423
|
+
const action = params.action || 'evaluate';
|
|
424
|
+
// 找到 pluginId 对应的 plugin 连接
|
|
425
|
+
let pluginWs = null;
|
|
426
|
+
for (const ws of pluginConnections) {
|
|
427
|
+
if (ws.readyState === WebSocket.OPEN && ws.pluginId === pluginId) { pluginWs = ws; break; }
|
|
428
|
+
}
|
|
429
|
+
if (!pluginWs) return { error: 'Browser not found or offline' };
|
|
430
|
+
|
|
431
|
+
// closebrowser 特殊处理:不走 /client(会被 Browser.close 断开),
|
|
432
|
+
// 直接用 pluginWs 发 client-disconnected + closeTarget
|
|
433
|
+
if (action === 'closebrowser') {
|
|
434
|
+
// 从 apiKey 找到对应的 key session 的端口,拿到正确的 clientId
|
|
435
|
+
let clientId = `pool_${PORT}`; // fallback
|
|
436
|
+
if (pluginWs.apiKey && portPool && portPool._keyToPortIndex) {
|
|
437
|
+
const portIdx = portPool._keyToPortIndex.get(pluginWs.apiKey);
|
|
438
|
+
if (portIdx !== undefined && portPool.portSessions[portIdx]) {
|
|
439
|
+
clientId = `pool_${portPool.portSessions[portIdx].port}`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// 发 client-disconnected 让扩展关分组 + 关 tab
|
|
443
|
+
try {
|
|
444
|
+
pluginWs.send(JSON.stringify({ type: 'client-disconnected', clientId }));
|
|
445
|
+
} catch (e) {}
|
|
446
|
+
// 清理 key session(让下次连接重新分配)
|
|
447
|
+
if (pluginWs.apiKey && portPool) {
|
|
448
|
+
portPool.keySessions.delete(pluginWs.apiKey);
|
|
449
|
+
if (portPool._keyToPortIndex) portPool._keyToPortIndex.delete(pluginWs.apiKey);
|
|
450
|
+
}
|
|
451
|
+
return { closed: true };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const apiKey = pluginWs.apiKey;
|
|
455
|
+
if (!apiKey) return { error: 'Browser has no API key (not connected via /plugin?key=)' };
|
|
456
|
+
|
|
457
|
+
// 内部连 /client?key=xxx,走端口池完整路径(建组 + session 路由 + 分组)
|
|
458
|
+
const port = PORT;
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/client?key=${apiKey}`);
|
|
461
|
+
const pending = new Map(); let id = 1; let sessionId = null;
|
|
462
|
+
const timeout = setTimeout(() => { try { ws.close(); } catch {}; resolve({ error: 'Timeout' }); }, 15000);
|
|
463
|
+
|
|
464
|
+
function cdp(method, p = {}, sid) {
|
|
465
|
+
return new Promise((res, rej) => {
|
|
466
|
+
const i = id++; pending.set(i, { resolve: res, reject: rej });
|
|
467
|
+
const o = { id: i, method, params: p };
|
|
468
|
+
if (sid) o.sessionId = sid; else if (sessionId) o.sessionId = sessionId;
|
|
469
|
+
ws.send(JSON.stringify(o));
|
|
470
|
+
setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error('CDP timeout: ' + method)); } }, 12000);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function finish(result) { clearTimeout(timeout); try { ws.close(); } catch {}; resolve(result); }
|
|
475
|
+
|
|
476
|
+
ws.on('message', async (data) => {
|
|
477
|
+
const m = JSON.parse(data.toString());
|
|
478
|
+
if (m.id && pending.has(m.id)) {
|
|
479
|
+
const { resolve: r, reject: rj } = pending.get(m.id); pending.delete(m.id);
|
|
480
|
+
m.error ? rj(new Error(JSON.stringify(m.error))) : r(m.result);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// attachedToTarget 事件 → 记 sessionId
|
|
484
|
+
if (m.method === 'Target.attachedToTarget' && m.params?.sessionId && !sessionId) {
|
|
485
|
+
sessionId = m.params.sessionId;
|
|
486
|
+
try {
|
|
487
|
+
if (action === 'evaluate') {
|
|
488
|
+
await cdp('Runtime.enable', {}, sessionId);
|
|
489
|
+
const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
|
|
490
|
+
finish({ result: ev.result.value });
|
|
491
|
+
} else if (action === 'screenshot') {
|
|
492
|
+
await cdp('Page.enable', {}, sessionId);
|
|
493
|
+
const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
494
|
+
finish({ screenshot: shot.data });
|
|
495
|
+
}
|
|
496
|
+
} catch (e) { finish({ error: e.message }); }
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
ws.on('open', async () => {
|
|
501
|
+
try {
|
|
502
|
+
// listtabs:走 /client 路径,getTargets 被端口池按 key 自动过滤
|
|
503
|
+
if (action === 'listtabs') {
|
|
504
|
+
await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
505
|
+
const tg = await cdp('Target.getTargets');
|
|
506
|
+
const tabs = (tg?.targetInfos || [])
|
|
507
|
+
.filter(t => t.type === 'page')
|
|
508
|
+
.map(t => ({ targetId: t.targetId, url: t.url, title: t.title || '', attached: t.attached }));
|
|
509
|
+
finish({ tabs });
|
|
510
|
+
}
|
|
511
|
+
// closetab:关闭指定 targetId
|
|
512
|
+
else if (action === 'closetab') {
|
|
513
|
+
await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
514
|
+
await cdp('Target.closeTarget', { targetId: params.targetId });
|
|
515
|
+
finish({ closed: true, targetId: params.targetId });
|
|
516
|
+
}
|
|
517
|
+
// switchtab:切换到指定 targetId(bringToFront)
|
|
518
|
+
else if (action === 'switchtab') {
|
|
519
|
+
await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
520
|
+
const at = await cdp('Target.attachToTarget', { targetId: params.targetId, flatten: true });
|
|
521
|
+
if (at.sessionId) {
|
|
522
|
+
await cdp('Page.enable', {}, at.sessionId);
|
|
523
|
+
await cdp('Page.bringToFront', {}, at.sessionId);
|
|
524
|
+
}
|
|
525
|
+
finish({ switched: true, targetId: params.targetId });
|
|
526
|
+
}
|
|
527
|
+
else if (action === 'newtab') {
|
|
528
|
+
await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
529
|
+
const created = await cdp('Target.createTarget', { url: params.url || 'about:blank' });
|
|
530
|
+
finish({ targetId: created.targetId });
|
|
531
|
+
} else {
|
|
532
|
+
// evaluate/screenshot:直接 getTargets + attach(不等 setAutoAttach 事件,避免超时)
|
|
533
|
+
await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
534
|
+
const tg = await cdp('Target.getTargets');
|
|
535
|
+
let page = (tg.targetInfos || []).find(t => t.type === 'page');
|
|
536
|
+
if (!page) {
|
|
537
|
+
const created = await cdp('Target.createTarget', { url: 'about:blank' });
|
|
538
|
+
page = { targetId: created.targetId };
|
|
539
|
+
}
|
|
540
|
+
// attach 后等 attachedToTarget 事件触发 evaluate/screenshot
|
|
541
|
+
// 如果事件没来,1 秒后用手动方式(attach 响应里的 sessionId)
|
|
542
|
+
const at = await cdp('Target.attachToTarget', { targetId: page.targetId, flatten: true });
|
|
543
|
+
// 如果事件路径没处理,手动执行
|
|
544
|
+
setTimeout(async () => {
|
|
545
|
+
if (sessionId || !at.sessionId) return;
|
|
546
|
+
sessionId = at.sessionId;
|
|
547
|
+
try {
|
|
548
|
+
if (action === 'evaluate') {
|
|
549
|
+
await cdp('Runtime.enable', {}, sessionId);
|
|
550
|
+
const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
|
|
551
|
+
finish({ result: ev.result.value });
|
|
552
|
+
} else if (action === 'screenshot') {
|
|
553
|
+
await cdp('Page.enable', {}, sessionId);
|
|
554
|
+
const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
555
|
+
finish({ screenshot: shot.data });
|
|
556
|
+
}
|
|
557
|
+
} catch (e) { finish({ error: e.message }); }
|
|
558
|
+
}, 1000);
|
|
559
|
+
}
|
|
560
|
+
} catch (e) { finish({ error: e.message }); }
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
ws.on('error', e => { clearTimeout(timeout); resolve({ error: e.message }); });
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
320
567
|
async function handleHttpRequest(req, res) {
|
|
321
568
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
322
|
-
|
|
569
|
+
|
|
570
|
+
// 主端口(9221)的 /json/version、/json/list 委托端口池 session[0],
|
|
571
|
+
// 语义与 9231-9239 一致:只返回本端口(pool_9221)创建的 target。
|
|
572
|
+
// takeover(9220,req._takeoverMode=true)不走这条——它要看用户全部 tab。
|
|
573
|
+
if (!req._takeoverMode && portPool && portPool.portSessions[0] &&
|
|
574
|
+
(url.pathname === '/json/version' || url.pathname === '/json/version/' ||
|
|
575
|
+
url.pathname === '/json' || url.pathname === '/json/' ||
|
|
576
|
+
url.pathname === '/json/list' || url.pathname === '/json/list/' ||
|
|
577
|
+
url.pathname === '/json/new')) {
|
|
578
|
+
portPool._handleHttp(req, res, portPool.portSessions[0]);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 管理控制台:/admin 返回页面,/admin/api/* 返回 JSON
|
|
583
|
+
if (url.pathname === '/admin' || url.pathname === '/admin/') {
|
|
584
|
+
try {
|
|
585
|
+
const html = fs.readFileSync(path.join(__dirname, 'admin-console.html'), 'utf8');
|
|
586
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
587
|
+
res.end(html);
|
|
588
|
+
} catch (e) {
|
|
589
|
+
res.writeHead(500); res.end('Admin console not found: ' + e.message);
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (url.pathname.startsWith('/admin/api/')) {
|
|
594
|
+
return handleAdminApi(req, res, url);
|
|
595
|
+
}
|
|
596
|
+
|
|
323
597
|
if (url.pathname === '/json/browsers' || url.pathname === '/json/browsers/') {
|
|
324
598
|
const browsers = [];
|
|
325
599
|
for (const pluginWs of pluginConnections) {
|
|
@@ -465,6 +739,19 @@ wss.on('connection', (ws, req) => {
|
|
|
465
739
|
}
|
|
466
740
|
}
|
|
467
741
|
|
|
742
|
+
// 主端口(9221)的 create 连接走端口池第 0 个 session(与 9231-9239 行为一致)
|
|
743
|
+
// 注意:takeover(9220,req._takeoverMode=true)不走端口池,保留原 handleClientConnection 逻辑
|
|
744
|
+
if (!req._takeoverMode && portPool && portPool.portSessions[0]) {
|
|
745
|
+
const isMainPortClient = path === '/client' ||
|
|
746
|
+
path.startsWith('/client/') ||
|
|
747
|
+
path.startsWith('/devtools/browser/') ||
|
|
748
|
+
path.startsWith('/devtools/page/');
|
|
749
|
+
if (isMainPortClient) {
|
|
750
|
+
portPool._handleClientConnect(ws, req, portPool.portSessions[0]);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
468
755
|
const clientInfo = {
|
|
469
756
|
ip: req.socket.remoteAddress,
|
|
470
757
|
port: req.socket.remotePort
|
|
@@ -473,6 +760,7 @@ wss.on('connection', (ws, req) => {
|
|
|
473
760
|
if (path === '/plugin') {
|
|
474
761
|
handlePluginConnection(ws, clientInfo, req);
|
|
475
762
|
} else if (path === '/client' || path.startsWith('/client/') || path.startsWith('/client-') || path.startsWith('/devtools/browser/')) {
|
|
763
|
+
// 仅 takeover(9220)会走到这里;create 已被上面的端口池分支拦截
|
|
476
764
|
const customClientId = path.startsWith('/client-') ? path.replace('/client-', '') : null;
|
|
477
765
|
let targetPluginId = null;
|
|
478
766
|
if (pathParts[0] === 'client' && pathParts[1]) {
|
|
@@ -483,6 +771,7 @@ wss.on('connection', (ws, req) => {
|
|
|
483
771
|
const mode = req._takeoverMode ? 'takeover' : 'create';
|
|
484
772
|
handleClientConnection(ws, clientInfo, customClientId, targetPluginId, mode);
|
|
485
773
|
} else if (path.startsWith('/devtools/page/')) {
|
|
774
|
+
// 仅 takeover 会走到这里
|
|
486
775
|
const targetId = path.replace('/devtools/page/', '');
|
|
487
776
|
const mode = req._takeoverMode ? 'takeover' : 'create';
|
|
488
777
|
handlePageConnection(ws, clientInfo, targetId, mode);
|
|
@@ -715,11 +1004,20 @@ function handlePluginConnection(ws, clientInfo, request) {
|
|
|
715
1004
|
}
|
|
716
1005
|
}
|
|
717
1006
|
|
|
1007
|
+
if (HAS_SAAS && process.env.REQUIRE_AUTH === 'true' && !apiKey) {
|
|
1008
|
+
// 强制鉴权模式:plugin 必须带 key
|
|
1009
|
+
logConnectionEvent('PLUGIN_AUTH_FAIL', 'No API key (REQUIRE_AUTH=true)');
|
|
1010
|
+
ws.close(4001, 'API key required');
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
718
1014
|
if (HAS_SAAS && apiKey) {
|
|
719
1015
|
const keyInfo = validateApiKey(apiKey);
|
|
720
1016
|
if (keyInfo) {
|
|
721
1017
|
ws.userId = keyInfo.userId;
|
|
722
1018
|
ws.apiKeyId = keyInfo.keyId;
|
|
1019
|
+
ws.apiKey = apiKey; // 记录 key 字符串,供 client 连接时反查 plugin
|
|
1020
|
+
ws.apiKeyName = keyInfo.keyName; // key 名称,用作 Chrome 分组名
|
|
723
1021
|
logConnectionEvent('PLUGIN_AUTHED', `userId=${keyInfo.userId} keyName=${keyInfo.keyName}`);
|
|
724
1022
|
} else {
|
|
725
1023
|
logConnectionEvent('PLUGIN_AUTH_FAIL', 'Invalid API key');
|
|
@@ -835,12 +1133,18 @@ function handlePluginConnection(ws, clientInfo, request) {
|
|
|
835
1133
|
const extVersion = parsed.version || 'unknown';
|
|
836
1134
|
ws.extVersion = extVersion;
|
|
837
1135
|
const match = extVersion === PKG_VERSION;
|
|
838
|
-
const level = match ? 'info' : 'warn';
|
|
839
1136
|
const label = match ? '✅' : '⚠️ VERSION MISMATCH';
|
|
840
1137
|
const msg = `[VERSION CHECK] ${label} server=${PKG_VERSION} extension=${extVersion}`;
|
|
841
1138
|
console.log(msg);
|
|
842
1139
|
logCDP('VERSION', msg);
|
|
843
1140
|
if (!match) {
|
|
1141
|
+
// STRICT_VERSION=true(生产环境):版本不一致直接拒绝,避免老扩展与新 proxy 不兼容
|
|
1142
|
+
if (process.env.STRICT_VERSION === 'true') {
|
|
1143
|
+
console.log(` ↳ STRICT_VERSION=true,拒绝连接`);
|
|
1144
|
+
logConnectionEvent('PLUGIN_VERSION_REJECTED', `server=${PKG_VERSION} extension=${extVersion}`);
|
|
1145
|
+
ws.close(4002, `Version mismatch: server=${PKG_VERSION} extension=${extVersion}`);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
844
1148
|
console.log(` ↳ Run "cdp-tunnel update" or reload the extension to sync versions`);
|
|
845
1149
|
}
|
|
846
1150
|
getNamespace(ws).cachedBrowserVersion = null;
|
|
@@ -2101,10 +2405,25 @@ server.listen(PORT, '0.0.0.0');
|
|
|
2101
2405
|
|
|
2102
2406
|
// v3.0 端口池启动(唯一模式)
|
|
2103
2407
|
portPool = new PortPoolManager({
|
|
2104
|
-
getPluginConnection: () => {
|
|
2408
|
+
getPluginConnection: (apiKey) => {
|
|
2409
|
+
// 鉴权模式:按 key 找对应的 plugin(一 key 一浏览器)
|
|
2410
|
+
if (apiKey) {
|
|
2411
|
+
for (const ws of pluginConnections) {
|
|
2412
|
+
if (ws.readyState === WebSocket.OPEN && ws.apiKey === apiKey) return ws;
|
|
2413
|
+
}
|
|
2414
|
+
return null;
|
|
2415
|
+
}
|
|
2416
|
+
// 无鉴权模式(本地开发):返回第一个 plugin
|
|
2105
2417
|
for (const ws of pluginConnections) return ws;
|
|
2106
2418
|
return null;
|
|
2107
2419
|
},
|
|
2420
|
+
validateClientKey: (apiKey) => {
|
|
2421
|
+
// 无 SaaS 或未开启强制鉴权时放行(本地开发模式)
|
|
2422
|
+
if (!HAS_SAAS || process.env.REQUIRE_AUTH !== 'true') return true;
|
|
2423
|
+
// 开启强制鉴权:必须有有效 key
|
|
2424
|
+
if (!apiKey) return false;
|
|
2425
|
+
return !!validateApiKey(apiKey);
|
|
2426
|
+
},
|
|
2108
2427
|
handlePluginUpgrade: (req, socket, head) => {
|
|
2109
2428
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2110
2429
|
wss.emit('connection', ws, req);
|
package/server/saas/auth.js
CHANGED
|
@@ -85,7 +85,7 @@ function validateApiKey(key) {
|
|
|
85
85
|
const apiKey = db.prepare('SELECT * FROM api_keys WHERE key = ? AND active = 1').get(key);
|
|
86
86
|
if (!apiKey) return null;
|
|
87
87
|
// 更新最后使用时间
|
|
88
|
-
db.prepare(
|
|
88
|
+
db.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?").run(apiKey.id);
|
|
89
89
|
return { userId: apiKey.user_id, keyId: apiKey.id, keyName: apiKey.name };
|
|
90
90
|
}
|
|
91
91
|
|