cdp-tunnel 3.5.0 → 3.6.1

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,8 +24,10 @@ let portPool = null;
24
24
  const { logCDP, logEvent, clearLog, logStatus, logConnectionEvent, flushAllLogs, logDisconnect } = require('./modules/logger');
25
25
 
26
26
  let validateApiKey = null;
27
+ let saasAuth = null; // 完整 SaaS auth 模块(createApiKey/listApiKeys/revokeApiKey)
27
28
  try {
28
- validateApiKey = require('./saas/auth').validateApiKey;
29
+ saasAuth = require('./saas/auth');
30
+ validateApiKey = saasAuth.validateApiKey;
29
31
  var HAS_SAAS = true;
30
32
  } catch (e) {
31
33
  var HAS_SAAS = false;
@@ -164,7 +166,6 @@ const clientConnections = new Set();
164
166
  class PluginNamespace {
165
167
  constructor() {
166
168
  this.sessionToClientId = new Map();
167
- this.pendingAttachRequests = new Map();
168
169
  this.pendingAttachedEvents = new Map();
169
170
  this.pendingTargetCreatedEvents = new Map();
170
171
  this.targetIdToClientId = new Map();
@@ -318,6 +319,250 @@ function resolvePluginFromUrl(url) {
318
319
  return pluginConnections.values().next().value || null;
319
320
  }
320
321
 
322
+ // ===== 管理控制台 API =====
323
+ // 鉴权:ADMIN_TOKEN 环境变量(Bearer token 或 query ?token=)
324
+ // 未设 ADMIN_TOKEN 时只允许 localhost(开发友好)
325
+ function checkAdminAuth(req, url) {
326
+ const token = process.env.ADMIN_TOKEN;
327
+ if (!token) {
328
+ // 未设 token:只允许 localhost
329
+ const ip = req.socket.remoteAddress;
330
+ return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
331
+ }
332
+ // 设了 token:校验 Bearer 或 query
333
+ const authHeader = req.headers.authorization || '';
334
+ const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
335
+ const queryToken = url.searchParams.get('token');
336
+ return bearer === token || queryToken === token;
337
+ }
338
+
339
+ function adminJson(res, code, data) {
340
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
341
+ res.end(JSON.stringify(data));
342
+ }
343
+
344
+ async function readBody(req) {
345
+ return new Promise(resolve => {
346
+ let d = ''; req.on('data', c => d += c);
347
+ req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } });
348
+ });
349
+ }
350
+
351
+ async function handleAdminApi(req, res, url) {
352
+ const path = url.pathname.replace('/admin/api/', '');
353
+ const method = req.method;
354
+
355
+ if (!checkAdminAuth(req, url)) {
356
+ return adminJson(res, 401, { error: 'Unauthorized. Set ADMIN_TOKEN env or access from localhost.' });
357
+ }
358
+
359
+ try {
360
+ // 浏览器列表
361
+ if (path === 'browsers' && method === 'GET') {
362
+ const browsers = [];
363
+ for (const pluginWs of pluginConnections) {
364
+ if (pluginWs.readyState !== WebSocket.OPEN) continue;
365
+ const ns = getNamespace(pluginWs);
366
+ browsers.push({
367
+ pluginId: pluginWs.pluginId,
368
+ pluginName: pluginWs.pluginName || 'My Browser',
369
+ userId: pluginWs.userId || null,
370
+ apiKeyId: pluginWs.apiKeyId || null,
371
+ browserName: ns.cachedBrowserVersion?.Browser || 'Unknown',
372
+ userAgent: ns.cachedBrowserVersion?.['User-Agent'] || '',
373
+ targets: ns.cachedTargets?.length || 0,
374
+ connected: true,
375
+ connectedAt: pluginWs.connectedAt,
376
+ extVersion: pluginWs.extVersion || 'unknown'
377
+ });
378
+ }
379
+ return adminJson(res, 200, browsers);
380
+ }
381
+
382
+ // key 管理(需要 SaaS)
383
+ if (path === 'keys' && method === 'GET') {
384
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
385
+ return adminJson(res, 200, saasAuth.listApiKeys('builtin-admin'));
386
+ }
387
+ if (path === 'keys' && method === 'POST') {
388
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
389
+ const body = await readBody(req);
390
+ const name = body.name || ('browser-' + Date.now().toString(36));
391
+ const result = saasAuth.createApiKey('builtin-admin', name);
392
+ const port = PORT;
393
+ return adminJson(res, 200, {
394
+ ...result,
395
+ pluginUrl: `ws://localhost:${port}/plugin?key=${result.key}`,
396
+ clientUrl: `ws://localhost:${port}/client?key=${result.key}`
397
+ });
398
+ }
399
+ if (path.startsWith('keys/') && method === 'DELETE') {
400
+ if (!saasAuth) return adminJson(res, 503, { error: 'SaaS not available' });
401
+ const keyId = path.split('/')[1];
402
+ const result = saasAuth.revokeApiKey(keyId, 'builtin-admin');
403
+ return adminJson(res, result.changes > 0 ? 200 : 404, { ok: result.changes > 0, changes: result.changes });
404
+ }
405
+
406
+ // CDP 快捷操作(通过 pluginId 直接连已在线浏览器)
407
+ if (path.startsWith('cdp/') && method === 'POST') {
408
+ const body = await readBody(req);
409
+ if (!body.pluginId) return adminJson(res, 400, { error: 'Missing pluginId' });
410
+ const result = await execCdpViaPlugin(body.pluginId, body);
411
+ return adminJson(res, result.error ? 500 : 200, result);
412
+ }
413
+
414
+ return adminJson(res, 404, { error: 'Not found: ' + path });
415
+ } catch (e) {
416
+ return adminJson(res, 500, { error: e.message });
417
+ }
418
+ }
419
+
420
+ // 通过 pluginId 走端口池 /client 路径执行 CDP 命令(复用完整分组/session 路由逻辑)
421
+ async function execCdpViaPlugin(pluginId, params) {
422
+ const action = params.action || 'evaluate';
423
+ // 找到 pluginId 对应的 plugin 连接
424
+ let pluginWs = null;
425
+ for (const ws of pluginConnections) {
426
+ if (ws.readyState === WebSocket.OPEN && ws.pluginId === pluginId) { pluginWs = ws; break; }
427
+ }
428
+ if (!pluginWs) return { error: 'Browser not found or offline' };
429
+
430
+ // closebrowser 特殊处理:不走 /client(会被 Browser.close 断开),
431
+ // 直接用 pluginWs 发 client-disconnected + closeTarget
432
+ if (action === 'closebrowser') {
433
+ // 从 apiKey 找到对应的 key session 的端口,拿到正确的 clientId
434
+ let clientId = `pool_${PORT}`; // fallback
435
+ if (pluginWs.apiKey && portPool && portPool._keyToPortIndex) {
436
+ const portIdx = portPool._keyToPortIndex.get(pluginWs.apiKey);
437
+ if (portIdx !== undefined && portPool.portSessions[portIdx]) {
438
+ clientId = `pool_${portPool.portSessions[portIdx].port}`;
439
+ }
440
+ }
441
+ // 发 client-disconnected 让扩展关分组 + 关 tab
442
+ try {
443
+ pluginWs.send(JSON.stringify({ type: 'client-disconnected', clientId }));
444
+ } catch (e) {}
445
+ // 清理 key session(让下次连接重新分配)
446
+ if (pluginWs.apiKey && portPool) {
447
+ portPool.keySessions.delete(pluginWs.apiKey);
448
+ if (portPool._keyToPortIndex) portPool._keyToPortIndex.delete(pluginWs.apiKey);
449
+ }
450
+ return { closed: true };
451
+ }
452
+
453
+ const apiKey = pluginWs.apiKey;
454
+ if (!apiKey) return { error: 'Browser has no API key (not connected via /plugin?key=)' };
455
+
456
+ // 内部连 /client?key=xxx,走端口池完整路径(建组 + session 路由 + 分组)
457
+ const port = PORT;
458
+ return new Promise((resolve) => {
459
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/client?key=${apiKey}`);
460
+ const pending = new Map(); let id = 1; let sessionId = null;
461
+ const timeout = setTimeout(() => { try { ws.close(); } catch {}; resolve({ error: 'Timeout' }); }, 15000);
462
+
463
+ function cdp(method, p = {}, sid) {
464
+ return new Promise((res, rej) => {
465
+ const i = id++; pending.set(i, { resolve: res, reject: rej });
466
+ const o = { id: i, method, params: p };
467
+ if (sid) o.sessionId = sid; else if (sessionId) o.sessionId = sessionId;
468
+ ws.send(JSON.stringify(o));
469
+ setTimeout(() => { if (pending.has(i)) { pending.delete(i); rej(new Error('CDP timeout: ' + method)); } }, 12000);
470
+ });
471
+ }
472
+
473
+ function finish(result) { clearTimeout(timeout); try { ws.close(); } catch {}; resolve(result); }
474
+
475
+ ws.on('message', async (data) => {
476
+ const m = JSON.parse(data.toString());
477
+ if (m.id && pending.has(m.id)) {
478
+ const { resolve: r, reject: rj } = pending.get(m.id); pending.delete(m.id);
479
+ m.error ? rj(new Error(JSON.stringify(m.error))) : r(m.result);
480
+ return;
481
+ }
482
+ // attachedToTarget 事件 → 记 sessionId
483
+ if (m.method === 'Target.attachedToTarget' && m.params?.sessionId && !sessionId) {
484
+ sessionId = m.params.sessionId;
485
+ try {
486
+ if (action === 'evaluate') {
487
+ await cdp('Runtime.enable', {}, sessionId);
488
+ const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
489
+ finish({ result: ev.result.value });
490
+ } else if (action === 'screenshot') {
491
+ await cdp('Page.enable', {}, sessionId);
492
+ const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
493
+ finish({ screenshot: shot.data });
494
+ }
495
+ } catch (e) { finish({ error: e.message }); }
496
+ }
497
+ });
498
+
499
+ ws.on('open', async () => {
500
+ try {
501
+ // listtabs:走 /client 路径,getTargets 被端口池按 key 自动过滤
502
+ if (action === 'listtabs') {
503
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
504
+ const tg = await cdp('Target.getTargets');
505
+ const tabs = (tg?.targetInfos || [])
506
+ .filter(t => t.type === 'page')
507
+ .map(t => ({ targetId: t.targetId, url: t.url, title: t.title || '', attached: t.attached }));
508
+ finish({ tabs });
509
+ }
510
+ // closetab:关闭指定 targetId
511
+ else if (action === 'closetab') {
512
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
513
+ await cdp('Target.closeTarget', { targetId: params.targetId });
514
+ finish({ closed: true, targetId: params.targetId });
515
+ }
516
+ // switchtab:切换到指定 targetId(bringToFront)
517
+ else if (action === 'switchtab') {
518
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
519
+ const at = await cdp('Target.attachToTarget', { targetId: params.targetId, flatten: true });
520
+ if (at.sessionId) {
521
+ await cdp('Page.enable', {}, at.sessionId);
522
+ await cdp('Page.bringToFront', {}, at.sessionId);
523
+ }
524
+ finish({ switched: true, targetId: params.targetId });
525
+ }
526
+ else if (action === 'newtab') {
527
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
528
+ const created = await cdp('Target.createTarget', { url: params.url || 'about:blank' });
529
+ finish({ targetId: created.targetId });
530
+ } else {
531
+ // evaluate/screenshot:直接 getTargets + attach(不等 setAutoAttach 事件,避免超时)
532
+ await cdp('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
533
+ const tg = await cdp('Target.getTargets');
534
+ let page = (tg.targetInfos || []).find(t => t.type === 'page');
535
+ if (!page) {
536
+ const created = await cdp('Target.createTarget', { url: 'about:blank' });
537
+ page = { targetId: created.targetId };
538
+ }
539
+ // attach 后等 attachedToTarget 事件触发 evaluate/screenshot
540
+ // 如果事件没来,1 秒后用手动方式(attach 响应里的 sessionId)
541
+ const at = await cdp('Target.attachToTarget', { targetId: page.targetId, flatten: true });
542
+ // 如果事件路径没处理,手动执行
543
+ setTimeout(async () => {
544
+ if (sessionId || !at.sessionId) return;
545
+ sessionId = at.sessionId;
546
+ try {
547
+ if (action === 'evaluate') {
548
+ await cdp('Runtime.enable', {}, sessionId);
549
+ const ev = await cdp('Runtime.evaluate', { expression: params.expression || 'document.title', returnByValue: true }, sessionId);
550
+ finish({ result: ev.result.value });
551
+ } else if (action === 'screenshot') {
552
+ await cdp('Page.enable', {}, sessionId);
553
+ const shot = await cdp('Page.captureScreenshot', { format: 'png' }, sessionId);
554
+ finish({ screenshot: shot.data });
555
+ }
556
+ } catch (e) { finish({ error: e.message }); }
557
+ }, 1000);
558
+ }
559
+ } catch (e) { finish({ error: e.message }); }
560
+ });
561
+
562
+ ws.on('error', e => { clearTimeout(timeout); resolve({ error: e.message }); });
563
+ });
564
+ }
565
+
321
566
  async function handleHttpRequest(req, res) {
322
567
  const url = new URL(req.url, `http://localhost:${PORT}`);
323
568
 
@@ -333,6 +578,21 @@ async function handleHttpRequest(req, res) {
333
578
  return;
334
579
  }
335
580
 
581
+ // 管理控制台:/admin 返回页面,/admin/api/* 返回 JSON
582
+ if (url.pathname === '/admin' || url.pathname === '/admin/') {
583
+ try {
584
+ const html = fs.readFileSync(path.join(__dirname, 'admin-console.html'), 'utf8');
585
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
586
+ res.end(html);
587
+ } catch (e) {
588
+ res.writeHead(500); res.end('Admin console not found: ' + e.message);
589
+ }
590
+ return;
591
+ }
592
+ if (url.pathname.startsWith('/admin/api/')) {
593
+ return handleAdminApi(req, res, url);
594
+ }
595
+
336
596
  if (url.pathname === '/json/browsers' || url.pathname === '/json/browsers/') {
337
597
  const browsers = [];
338
598
  for (const pluginWs of pluginConnections) {
@@ -707,8 +967,7 @@ function cleanupPlugin(ws, id, reason) {
707
967
  remainingPlugins: pluginConnections.size,
708
968
  affectedClients,
709
969
  uptime: ws.connectedAt ? `${((Date.now() - ws.connectedAt) / 1000).toFixed(0)}s` : 'unknown',
710
- activeSessions: ns.sessionToClientId.size,
711
- pendingRequests: ns.pendingAttachRequests.size
970
+ activeSessions: ns.sessionToClientId.size
712
971
  });
713
972
 
714
973
  if (ws.pairedClientId) {
@@ -756,6 +1015,7 @@ function handlePluginConnection(ws, clientInfo, request) {
756
1015
  ws.userId = keyInfo.userId;
757
1016
  ws.apiKeyId = keyInfo.keyId;
758
1017
  ws.apiKey = apiKey; // 记录 key 字符串,供 client 连接时反查 plugin
1018
+ ws.apiKeyName = keyInfo.keyName; // key 名称,用作 Chrome 分组名
759
1019
  logConnectionEvent('PLUGIN_AUTHED', `userId=${keyInfo.userId} keyName=${keyInfo.keyName}`);
760
1020
  } else {
761
1021
  logConnectionEvent('PLUGIN_AUTH_FAIL', 'Invalid API key');
@@ -1123,23 +1383,7 @@ function handlePluginConnection(ws, clientInfo, request) {
1123
1383
  invalidateTargetsCache(ws);
1124
1384
  }
1125
1385
 
1126
- if (mapping.isAutoDefaultPage) {
1127
- console.log(`[AUTO DEFAULT PAGE] createTarget response received for client=${mapping.clientId}, targetId=${parsed.result?.targetId?.substring(0,8) || 'none'} — skipping response send to client`);
1128
-
1129
- if (mapping.pendingSetAutoAttach) {
1130
- const pending = mapping.pendingSetAutoAttach;
1131
- const pendingParsed = pending.parsed;
1132
- const pendingClientId = pending.clientId;
1133
-
1134
- console.log(`[AUTO DEFAULT PAGE] Now forwarding pending setAutoAttach for client=${pendingClientId}`);
1135
-
1136
- if (ws.readyState === WebSocket.OPEN) {
1137
- const forwardMsg = { ...pendingParsed, __clientId: pendingClientId };
1138
- ws.send(JSON.stringify(forwardMsg));
1139
- console.log(`[SEND TO PLUGIN] Forwarding setAutoAttach for client=${pendingClientId}`);
1140
- }
1141
- }
1142
- } else {
1386
+ {
1143
1387
  const originalId = mapping.originalId;
1144
1388
  parsed.id = originalId;
1145
1389
  if (mapping.sessionId && !parsed.sessionId) {
@@ -1245,52 +1489,6 @@ function handlePluginConnection(ws, clientInfo, request) {
1245
1489
  }));
1246
1490
  }
1247
1491
 
1248
- function autoCreateDefaultPageAndForward(clientWs, setAutoAttachParsed, originalData, clientId, originalRequestId) {
1249
- const pluginWs = clientWs.pairedPlugin;
1250
- if (!pluginWs || pluginWs.readyState !== WebSocket.OPEN) {
1251
- forwardToPlugin(clientWs, originalData, clientId);
1252
- return;
1253
- }
1254
-
1255
- globalRequestIdCounter++;
1256
- const createGlobalId = globalRequestIdCounter;
1257
-
1258
- globalRequestIdMap.set(createGlobalId, {
1259
- clientId: clientId,
1260
- originalId: -1,
1261
- sessionId: null,
1262
- method: 'Target.createTarget',
1263
- isCreateTarget: true,
1264
- isAutoDefaultPage: true,
1265
- pendingSetAutoAttach: {
1266
- parsed: setAutoAttachParsed,
1267
- data: originalData,
1268
- clientId: clientId,
1269
- originalRequestId: originalRequestId
1270
- }
1271
- });
1272
-
1273
- const request = {
1274
- id: createGlobalId,
1275
- method: 'Target.createTarget',
1276
- params: { url: 'about:blank' },
1277
- __clientId: clientId
1278
- };
1279
-
1280
- console.log(`[AUTO DEFAULT PAGE] Sending Target.createTarget for client=${clientId} globalId=${createGlobalId}, will forward setAutoAttach after`);
1281
- pluginWs.send(JSON.stringify(request));
1282
- }
1283
-
1284
- function forwardToPlugin(clientWs, data, clientId) {
1285
- const pluginWs = clientWs.pairedPlugin;
1286
- if (pluginWs && pluginWs.readyState === WebSocket.OPEN) {
1287
- console.log(`[SEND TO PLUGIN] method=Target.setAutoAttach clientId=${clientId}`);
1288
- pluginWs.send(data);
1289
- } else {
1290
- broadcastToPlugins(data, clientWs);
1291
- }
1292
- }
1293
-
1294
1492
  /**
1295
1493
  * 处理 CDP 客户端连接 (Playwright/Puppeteer)
1296
1494
  */
@@ -1513,13 +1711,9 @@ function handleClientConnection(ws, clientInfo, customClientId = null, targetPlu
1513
1711
 
1514
1712
  if (parsed && parsed.method === 'Target.setAutoAttach' && parsed.params?.autoAttach && !ws._autoDefaultPageSent) {
1515
1713
  ws._autoDefaultPageSent = true;
1516
- if (ws.mode === 'takeover') {
1517
- const takeoverMsg = { ...parsed, __clientId: id, __mode: 'takeover' };
1518
- if (ws.pairedPlugin && ws.pairedPlugin.readyState === WebSocket.OPEN) {
1519
- ws.pairedPlugin.send(JSON.stringify(takeoverMsg));
1520
- }
1521
- } else {
1522
- autoCreateDefaultPageAndForward(ws, parsed, modifiedData, id, originalId);
1714
+ const forwardMsg = { ...parsed, __clientId: id, __mode: ws.mode };
1715
+ if (ws.pairedPlugin && ws.pairedPlugin.readyState === WebSocket.OPEN) {
1716
+ ws.pairedPlugin.send(JSON.stringify(forwardMsg));
1523
1717
  }
1524
1718
  return;
1525
1719
  }
@@ -2069,10 +2263,8 @@ setInterval(() => {
2069
2263
  });
2070
2264
 
2071
2265
  let totalSessions = 0;
2072
- let totalPendingAttach = 0;
2073
2266
  pluginNamespaces.forEach(ns => {
2074
2267
  totalSessions += ns.sessionToClientId.size;
2075
- totalPendingAttach += ns.pendingAttachRequests.size;
2076
2268
  });
2077
2269
 
2078
2270
  logStatus({
@@ -2084,8 +2276,7 @@ setInterval(() => {
2084
2276
  pairs: connectionPairs.size,
2085
2277
  pluginDetails: pluginList,
2086
2278
  clientDetails: clientList,
2087
- sessions: totalSessions,
2088
- pendingAttach: totalPendingAttach
2279
+ sessions: totalSessions
2089
2280
  });
2090
2281
  }, CONFIG.STATUS_PRINT_INTERVAL);
2091
2282
 
@@ -2162,11 +2353,6 @@ portPool = new PortPoolManager({
2162
2353
  if (!apiKey) return false;
2163
2354
  return !!validateApiKey(apiKey);
2164
2355
  },
2165
- handlePluginUpgrade: (req, socket, head) => {
2166
- wss.handleUpgrade(req, socket, head, (ws) => {
2167
- wss.emit('connection', ws, req);
2168
- });
2169
- },
2170
2356
  handleTakeoverUpgrade: (req, socket, head) => {
2171
2357
  req._takeoverMode = true;
2172
2358
  const url = new URL(req.url, `http://localhost:${CONFIG.TAKEOVER_PORT}`);
@@ -2186,22 +2372,6 @@ portPool = new PortPoolManager({
2186
2372
  wss.handleUpgrade(req, socket, head, (ws) => {
2187
2373
  wss.emit('connection', ws, req);
2188
2374
  });
2189
- },
2190
- getAllTargets: async (portIndex) => {
2191
- const session = portPool.portSessions[portIndex];
2192
- if (!session) return [];
2193
- // 从主 proxy 的 namespace 拿所有 target,过滤出这个端口的
2194
- const targets = [];
2195
- for (const [, ns] of pluginNamespaces) {
2196
- if (ns.cachedTargets) {
2197
- for (const t of ns.cachedTargets) {
2198
- if (session.targetIds.has(t.targetId)) {
2199
- targets.push(t);
2200
- }
2201
- }
2202
- }
2203
- }
2204
- return targets;
2205
2375
  }
2206
2376
  });
2207
2377
  portPool.start();