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.
@@ -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
- // 启动 create 端口(9222-9230
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
- // 清理 sessionId → client 映射(找到该 targetId 对应的 session 并删除)
363
- for (const [sid, ws] of session.sessionToClient.entries()) {
364
- // 通过 targetId 反查 sessionId(targetCreated 事件建立的映射)
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 没匹配到——记录但不广播(避免 sessionId 串)
414
- // targetId 会在 createTarget 响应时注册
415
- return true;
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
- // 精确路由:只发给持有这个 sessionId client(不广播)
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
  }
@@ -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
- const { validateApiKey } = require('./saas/auth');
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);
@@ -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('UPDATE api_keys SET last_used_at = datetime("now") WHERE id = ?').run(apiKey.id);
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