cdp-tunnel 3.3.9 → 3.5.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.
@@ -295,6 +295,11 @@
295
295
  margin-bottom: 2px;
296
296
  }
297
297
 
298
+ .auth-badge {
299
+ font-size: 11px;
300
+ cursor: help;
301
+ }
302
+
298
303
  .conn-config-url {
299
304
  font-size: 11px;
300
305
  color: #6b7280;
@@ -103,14 +103,16 @@
103
103
  var isActive = conn.enabled && statusClass === 'connected';
104
104
  var cdpAddr = getCdpAddress(conn.url, conn.mode);
105
105
  var statusHtml = getStatusHtml(statusClass);
106
+ var hasKey = (conn.url || '').indexOf('key=') >= 0;
107
+ var maskedUrl = maskUrl(conn.url);
106
108
  html +=
107
109
  '<div class="conn-config-item' + (isActive ? ' active' : '') + '" data-id="' + conn.id + '">' +
108
110
  '<input type="checkbox" class="conn-toggle" data-id="' + conn.id + '"' + (conn.enabled ? ' checked' : '') + ' title="启用/禁用">' +
109
111
  '<span class="status-dot ' + statusClass + '" title="' + statusClass + '"></span>' +
110
112
  '<div class="conn-config-info">' +
111
- '<div class="conn-config-tag">' + (conn.mode === 'takeover' ? '🔗 ' : '🆕 ') + escapeHtml(conn.tag) + '</div>' +
112
- '<div class="conn-config-url" title="' + escapeAttr(conn.url) + '">WS: ' + escapeHtml(conn.url) + '</div>' +
113
- (cdpAddr ? '<div class="conn-config-cdp">CDP: <span class="cdp-addr" data-cdp="' + escapeAttr(cdpAddr) + '">' + escapeHtml(cdpAddr) + '</span> <button class="btn-copy-cdp" data-cdp="' + escapeAttr(cdpAddr) + '" title="复制 CDP 地址">📋</button></div>' : '') +
113
+ '<div class="conn-config-tag">' + (conn.mode === 'takeover' ? '🔗 ' : '🆕 ') + escapeHtml(conn.tag) + (hasKey ? ' <span class="auth-badge" title="带 API Key 鉴权">🔑</span>' : '') + '</div>' +
114
+ '<div class="conn-config-url" title="' + escapeAttr(maskedUrl) + '">WS: ' + escapeHtml(maskedUrl) + '</div>' +
115
+ (cdpAddr ? '<div class="conn-config-cdp">CDP: <span class="cdp-addr" data-cdp="' + escapeAttr(cdpAddr) + '">' + escapeHtml(cdpAddr.replace(/(key=)(cdp_[a-f0-9]{8})[a-f0-9]*/, '$1$2••••')) + '</span> <button class="btn-copy-cdp" data-cdp="' + escapeAttr(cdpAddr) + '" title="复制 CDP 地址(含 key)">📋</button></div>' : '') +
114
116
  '<div class="conn-config-status">' + statusHtml + '</div>' +
115
117
  '</div>' +
116
118
  '<button class="btn-delete" data-id="' + conn.id + '" title="删除">删除</button>' +
@@ -313,7 +315,16 @@
313
315
  var host = match[1];
314
316
  var port = parseInt(match[2], 10);
315
317
  if (mode === 'takeover') port += 1;
316
- return 'http://' + host + ':' + port;
318
+ // 保留 key(如果 URL 里带了 ?key=xxx,CDP 客户端连接也需要带)
319
+ var keyMatch = (wsUrl || '').match(/[?&]key=([^&]+)/);
320
+ var keyParam = keyMatch ? '?key=' + keyMatch[1] : '';
321
+ return 'http://' + host + ':' + port + keyParam;
322
+ }
323
+
324
+ // 显示 URL 时隐藏 key 的明文(防截图泄露),但保留可识别性
325
+ function maskUrl(wsUrl) {
326
+ if (!wsUrl) return '';
327
+ return wsUrl.replace(/([?&]key=)(cdp_[a-f0-9]{8})[a-f0-9]*/, '$1$2••••');
317
328
  }
318
329
 
319
330
  function init() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "CDP Bridge",
4
- "version": "3.3.9",
4
+ "version": "3.5.0",
5
5
  "description": "Chrome DevTools Protocol Bridge for Playwright/Puppeteer automation",
6
6
  "permissions": [
7
7
  "debugger",
@@ -1,5 +1,5 @@
1
1
  var Config = {
2
- WS_URL: 'ws://localhost:9221/plugin',
2
+ WS_URL: 'ws://localhost:43226/plugin',
3
3
  RECONNECT_DELAY: 3000,
4
4
  DEBUGGER_VERSION: '1.3',
5
5
  HEARTBEAT_INTERVAL: 25000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-tunnel",
3
- "version": "3.3.9",
3
+ "version": "3.5.0",
4
4
  "description": "Bridge Chrome's debugger API to WebSocket — control your existing browser with Playwright/Puppeteer via CDP",
5
5
  "main": "server/proxy-server.js",
6
6
  "bin": "./cli/index.js",
@@ -1,8 +1,13 @@
1
1
  const CONFIG = {
2
+ // 主端口:create 模式 + 扩展入口(/plugin)。
3
+ // 同时是端口池的逻辑第 0 个端口(pool_{PORT}),与 POOL_START-POOL_START+POOL_SIZE-1 行为一致。
2
4
  PORT: process.env.PORT ? parseInt(process.env.PORT) : 9221,
5
+ // takeover 唯一端口:接管用户已打开的 tab(不分组、断开不关 tab、全局仅一个连接)
3
6
  TAKEOVER_PORT: process.env.TAKEOVER_PORT ? parseInt(process.env.TAKEOVER_PORT) : 9220,
7
+ // 端口池 create 端口范围(9231-9239),每个端口 = 一个独立隔离浏览器
4
8
  POOL_START: process.env.POOL_START ? parseInt(process.env.POOL_START) : 9231,
5
9
  POOL_SIZE: process.env.POOL_SIZE ? parseInt(process.env.POOL_SIZE) : 9,
10
+ // 端口池的 takeover 端口(与 TAKEOVER_PORT 同值,由端口池的 _startTakeoverPort 实际监听)
6
11
  POOL_TAKEOVER_PORT: process.env.POOL_TAKEOVER_PORT ? parseInt(process.env.POOL_TAKEOVER_PORT) : 9220,
7
12
  HEARTBEAT_INTERVAL: 30000,
8
13
  STATUS_PRINT_INTERVAL: 60000,
@@ -26,7 +26,6 @@ class PortPoolManager {
26
26
  this.createWss = []; // [WebSocket.Server] 每个 create 端口一个
27
27
  this.portSessions = []; // [PortSession] 每个 create 端口一个
28
28
  this.takeoverServer = null;
29
- this.takeoverWss = null;
30
29
  this.targetToPort = new Map(); // targetId → portIndex(事件路由用)
31
30
  this.sessionToPort = new Map(); // CDP sessionId → portIndex
32
31
  }
@@ -51,16 +50,29 @@ class PortPoolManager {
51
50
  };
52
51
 
53
52
  start() {
54
- // 启动 create 端口(9222-9230
53
+ // 端口池第 0 个端口 = 主端口(9221),复用主 server 的 /client 入口(不另开 http.Server
54
+ this._setupMainPortSession(0, CONFIG.PORT);
55
+
56
+ // 启动 create 端口(9231-9239),portIndex 从 1 开始(0 留给主端口)
55
57
  for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
56
58
  const port = CONFIG.POOL_START + i;
57
- this._startCreatePort(i, port);
59
+ this._startCreatePort(i + 1, port);
58
60
  }
59
61
 
60
62
  // 启动 takeover 端口(9220)
61
63
  this._startTakeoverPort();
62
64
 
63
- console.log(`\n[PORT POOL] Started: takeover=${CONFIG.POOL_TAKEOVER_PORT}, create=${CONFIG.POOL_START}-${CONFIG.POOL_START + CONFIG.POOL_SIZE - 1}`);
65
+ 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}`);
66
+ }
67
+
68
+ /**
69
+ * 主端口(9221)作为端口池第 0 个端口:只建 PortSession,不开 http.Server。
70
+ * 9221 的 HTTP 和 upgrade 由主 proxy-server 处理,但走端口池的隔离逻辑。
71
+ */
72
+ _setupMainPortSession(portIndex, port) {
73
+ const session = new PortPoolManager.PortSession(portIndex, port);
74
+ this.portSessions[portIndex] = session;
75
+ console.log(`[CREATE PORT ${portIndex}] Main port ${port} (reuses main server)`);
64
76
  }
65
77
 
66
78
  _startCreatePort(portIndex, port) {
@@ -140,7 +152,7 @@ class PortPoolManager {
140
152
  const url = new URL(req.url, `http://localhost:${session.port}`);
141
153
  const path = url.pathname;
142
154
 
143
- if (path === '/json/version') {
155
+ if (path === '/json/version' || path === '/json/version/') {
144
156
  // 返回版本信息,webSocketDebuggerUrl 指向当前端口
145
157
  res.writeHead(200, { 'Content-Type': 'application/json' });
146
158
  res.end(JSON.stringify({
@@ -152,7 +164,7 @@ class PortPoolManager {
152
164
  return;
153
165
  }
154
166
 
155
- if (path === '/json' || path === '/json/list') {
167
+ if (path === '/json' || path === '/json/' || path === '/json/list' || path === '/json/list/') {
156
168
  // 返回这个端口创建的 tab 列表
157
169
  // 从 session.targetIds 构建(这些是 createTarget 时记录的)
158
170
  const targets = [];
@@ -186,9 +198,27 @@ class PortPoolManager {
186
198
  _handleClientConnect(ws, req, session) {
187
199
  session.clients.add(ws);
188
200
 
189
- const pluginWs = this.mainProxy.getPluginConnection();
201
+ // 鉴权:从 URL 提取 key,找到对应的 plugin(一 key 一浏览器)
202
+ let apiKey = null;
203
+ try {
204
+ const url = new URL(req.url, `http://localhost:${session.port}`);
205
+ apiKey = url.searchParams.get('key') || null;
206
+ } catch {}
207
+
208
+ // 如果有 SaaS 鉴权(HAS_SAAS),校验 key
209
+ if (this.mainProxy.validateClientKey) {
210
+ const valid = this.mainProxy.validateClientKey(apiKey);
211
+ if (!valid) {
212
+ ws.close(4001, 'Invalid or missing API key');
213
+ return;
214
+ }
215
+ }
216
+
217
+ ws.apiKey = apiKey; // 记录到 ws,后续命令转发时带上
218
+
219
+ const pluginWs = this.mainProxy.getPluginConnection(apiKey);
190
220
  if (!pluginWs) {
191
- ws.close(1011, 'No extension connected');
221
+ ws.close(1011, apiKey ? 'No browser connected for this key' : 'No extension connected');
192
222
  return;
193
223
  }
194
224
 
@@ -352,6 +382,17 @@ class PortPoolManager {
352
382
  msg.result.targetInfos = msg.result.targetInfos.filter(t => session.targetIds.has(t.targetId));
353
383
  }
354
384
 
385
+ // 如果是 closeTarget 响应,清理该 target 的所有映射
386
+ if (pending && pending.method === 'Target.closeTarget' && pending.params && pending.params.targetId) {
387
+ const closedTid = pending.params.targetId;
388
+ session.targetIds.delete(closedTid);
389
+ session.targetUrls.delete(closedTid);
390
+ this.targetToPort.delete(closedTid);
391
+ // sessionId → client 映射在 targetCreated/attachedToTarget 时建立,
392
+ // 但端口池层不维护 sessionId ↔ targetId 反查表,故此处不清理 sessionToClient
393
+ // (session 级隔离下,client 断开时整体清理即可)
394
+ }
395
+
355
396
  // 恢复原始 id,发给发起请求的 client
356
397
  const response = { ...msg, id: pending ? pending.originalId : this._parseOriginalId(originalId) };
357
398
  delete response.__portIndex;
@@ -368,11 +409,36 @@ class PortPoolManager {
368
409
 
369
410
  // 2. 事件消息(无 id):按 targetId 或 sessionId 路由
370
411
  if (msg.method && msg.params) {
371
- // 诊断:打印所有未匹配到的事件
412
+ // 提取 targetId(attachedToTarget 的在 params.targetInfo.targetId)
372
413
  let targetId = null;
373
414
  if (msg.params.targetId) targetId = msg.params.targetId;
374
415
  else if (msg.params.targetInfo && msg.params.targetInfo.targetId) targetId = msg.params.targetInfo.targetId;
375
416
 
417
+ // 关键:attachedToTarget 事件带 sessionId 时,必须注册 sessionToPort,
418
+ // 否则后续该 session 的 Page.*/Runtime.* 事件无法路由(Playwright evaluate 会卡死)。
419
+ // 这一步独立于 targetId 是否已知——auto-attach 场景下 targetId 可能尚未在 targetToPort 注册。
420
+ // 归属端口:优先用 targetId 查 targetToPort;查不到时,找唯一一个 pendingCreate 的端口,
421
+ // 再查不到则归到端口 0(POOL_SIZE=0 时只有端口 0;多端口时靠 createTarget 响应补注册 targetId→port)
422
+ if (msg.method === 'Target.attachedToTarget' && msg.params.sessionId) {
423
+ let portIdx = this.targetToPort.get(targetId);
424
+ if (portIdx === undefined) {
425
+ // 找 pendingCreate 的端口(createTarget 响应还没到,但事件先到了)
426
+ for (let pi = 0; pi < this.portSessions.length; pi++) {
427
+ if (this.portSessions[pi] && this.portSessions[pi].pendingCreate) { portIdx = pi; break; }
428
+ }
429
+ }
430
+ if (portIdx === undefined) portIdx = 0; // 兜底:归到主端口
431
+ const sess = this.portSessions[portIdx];
432
+ if (sess) {
433
+ if (targetId) {
434
+ sess.targetIds.add(targetId);
435
+ this.targetToPort.set(targetId, portIdx);
436
+ }
437
+ this.sessionToPort.set(msg.params.sessionId, portIdx);
438
+ sess.sessionToClient.delete(msg.params.sessionId); // auto-attach:广播
439
+ }
440
+ }
441
+
376
442
  if (targetId) {
377
443
  const portIndex = this.targetToPort.get(targetId);
378
444
  if (portIndex !== undefined) {
@@ -392,13 +458,37 @@ class PortPoolManager {
392
458
  sess.targetUrls.set(targetId, 'about:blank');
393
459
  this.targetToPort.set(targetId, pi);
394
460
  if (msg.method === 'Target.attachedToTarget') sess.pendingCreate = false;
461
+ // attachedToTarget 事件带 sessionId:必须注册 sessionToPort/sessionToClient,
462
+ // 否则后续 Page.*/Runtime.* 等 session 事件无法路由(Playwright newPage 会卡死)
463
+ if (msg.method === 'Target.attachedToTarget' && msg.params && msg.params.sessionId) {
464
+ this.sessionToPort.set(msg.params.sessionId, pi);
465
+ // auto-attach 场景无特定发起 client,广播给该端口所有 client
466
+ sess.sessionToClient.delete(msg.params.sessionId);
467
+ }
395
468
  this._broadcastToPort(pi, msg);
396
469
  return true;
397
470
  }
398
471
  }
399
- // pendingCreate 没匹配到——记录但不广播(避免 sessionId 串)
400
- // targetId 会在 createTarget 响应时注册
401
- return true;
472
+ // pendingCreate 没匹配到——仍需处理 setAutoAttach 自动触发的 attachedToTarget
473
+ // (Playwright/Puppeteer newPage 走的就是这条路径,不走 createTarget lock)
474
+ // 用 targetToPort 找到归属端口;若 targetId 也未知,广播给所有端口(保守)
475
+ if (msg.method === 'Target.attachedToTarget' && msg.params && msg.params.sessionId) {
476
+ const knownPort = this.targetToPort.get(targetId);
477
+ const portIdx = knownPort !== undefined ? knownPort : 0;
478
+ const sess = this.portSessions[portIdx];
479
+ if (sess) {
480
+ sess.targetIds.add(targetId);
481
+ this.targetToPort.set(targetId, portIdx);
482
+ this.sessionToPort.set(msg.params.sessionId, portIdx);
483
+ sess.sessionToClient.delete(msg.params.sessionId); // auto-attach:广播
484
+ this._broadcastToPort(portIdx, msg);
485
+ }
486
+ return true;
487
+ }
488
+ // targetCreated 无 pendingCreate 且非 attachedToTarget:记录但不广播
489
+ if (msg.method === 'Target.targetCreated') {
490
+ return true;
491
+ }
402
492
  }
403
493
  }
404
494
 
@@ -408,10 +498,13 @@ class PortPoolManager {
408
498
  if (portIndex !== undefined) {
409
499
  const sess = this.portSessions[portIndex];
410
500
  if (sess) {
411
- // 精确路由:只发给持有这个 sessionId client(不广播)
501
+ // 精确路由:有特定 owner 就单播,否则广播给该端口所有 client
502
+ // (auto-attach 场景无 owner,Playwright/Puppeteer 需要 Page.* 事件)
412
503
  const ownerWs = sess.sessionToClient.get(msg.sessionId);
413
504
  if (ownerWs && ownerWs.readyState === WebSocket.OPEN) {
414
505
  ownerWs.send(JSON.stringify(msg));
506
+ } else {
507
+ this._broadcastToPort(portIndex, msg);
415
508
  }
416
509
  return true;
417
510
  }
@@ -23,8 +23,9 @@ 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;
26
27
  try {
27
- const { validateApiKey } = require('./saas/auth');
28
+ validateApiKey = require('./saas/auth').validateApiKey;
28
29
  var HAS_SAAS = true;
29
30
  } catch (e) {
30
31
  var HAS_SAAS = false;
@@ -319,7 +320,19 @@ function resolvePluginFromUrl(url) {
319
320
 
320
321
  async function handleHttpRequest(req, res) {
321
322
  const url = new URL(req.url, `http://localhost:${PORT}`);
322
-
323
+
324
+ // 主端口(9221)的 /json/version、/json/list 委托端口池 session[0],
325
+ // 语义与 9231-9239 一致:只返回本端口(pool_9221)创建的 target。
326
+ // takeover(9220,req._takeoverMode=true)不走这条——它要看用户全部 tab。
327
+ if (!req._takeoverMode && portPool && portPool.portSessions[0] &&
328
+ (url.pathname === '/json/version' || url.pathname === '/json/version/' ||
329
+ url.pathname === '/json' || url.pathname === '/json/' ||
330
+ url.pathname === '/json/list' || url.pathname === '/json/list/' ||
331
+ url.pathname === '/json/new')) {
332
+ portPool._handleHttp(req, res, portPool.portSessions[0]);
333
+ return;
334
+ }
335
+
323
336
  if (url.pathname === '/json/browsers' || url.pathname === '/json/browsers/') {
324
337
  const browsers = [];
325
338
  for (const pluginWs of pluginConnections) {
@@ -465,6 +478,19 @@ wss.on('connection', (ws, req) => {
465
478
  }
466
479
  }
467
480
 
481
+ // 主端口(9221)的 create 连接走端口池第 0 个 session(与 9231-9239 行为一致)
482
+ // 注意:takeover(9220,req._takeoverMode=true)不走端口池,保留原 handleClientConnection 逻辑
483
+ if (!req._takeoverMode && portPool && portPool.portSessions[0]) {
484
+ const isMainPortClient = path === '/client' ||
485
+ path.startsWith('/client/') ||
486
+ path.startsWith('/devtools/browser/') ||
487
+ path.startsWith('/devtools/page/');
488
+ if (isMainPortClient) {
489
+ portPool._handleClientConnect(ws, req, portPool.portSessions[0]);
490
+ return;
491
+ }
492
+ }
493
+
468
494
  const clientInfo = {
469
495
  ip: req.socket.remoteAddress,
470
496
  port: req.socket.remotePort
@@ -473,6 +499,7 @@ wss.on('connection', (ws, req) => {
473
499
  if (path === '/plugin') {
474
500
  handlePluginConnection(ws, clientInfo, req);
475
501
  } else if (path === '/client' || path.startsWith('/client/') || path.startsWith('/client-') || path.startsWith('/devtools/browser/')) {
502
+ // 仅 takeover(9220)会走到这里;create 已被上面的端口池分支拦截
476
503
  const customClientId = path.startsWith('/client-') ? path.replace('/client-', '') : null;
477
504
  let targetPluginId = null;
478
505
  if (pathParts[0] === 'client' && pathParts[1]) {
@@ -483,6 +510,7 @@ wss.on('connection', (ws, req) => {
483
510
  const mode = req._takeoverMode ? 'takeover' : 'create';
484
511
  handleClientConnection(ws, clientInfo, customClientId, targetPluginId, mode);
485
512
  } else if (path.startsWith('/devtools/page/')) {
513
+ // 仅 takeover 会走到这里
486
514
  const targetId = path.replace('/devtools/page/', '');
487
515
  const mode = req._takeoverMode ? 'takeover' : 'create';
488
516
  handlePageConnection(ws, clientInfo, targetId, mode);
@@ -715,11 +743,19 @@ function handlePluginConnection(ws, clientInfo, request) {
715
743
  }
716
744
  }
717
745
 
746
+ if (HAS_SAAS && process.env.REQUIRE_AUTH === 'true' && !apiKey) {
747
+ // 强制鉴权模式:plugin 必须带 key
748
+ logConnectionEvent('PLUGIN_AUTH_FAIL', 'No API key (REQUIRE_AUTH=true)');
749
+ ws.close(4001, 'API key required');
750
+ return;
751
+ }
752
+
718
753
  if (HAS_SAAS && apiKey) {
719
754
  const keyInfo = validateApiKey(apiKey);
720
755
  if (keyInfo) {
721
756
  ws.userId = keyInfo.userId;
722
757
  ws.apiKeyId = keyInfo.keyId;
758
+ ws.apiKey = apiKey; // 记录 key 字符串,供 client 连接时反查 plugin
723
759
  logConnectionEvent('PLUGIN_AUTHED', `userId=${keyInfo.userId} keyName=${keyInfo.keyName}`);
724
760
  } else {
725
761
  logConnectionEvent('PLUGIN_AUTH_FAIL', 'Invalid API key');
@@ -835,12 +871,18 @@ function handlePluginConnection(ws, clientInfo, request) {
835
871
  const extVersion = parsed.version || 'unknown';
836
872
  ws.extVersion = extVersion;
837
873
  const match = extVersion === PKG_VERSION;
838
- const level = match ? 'info' : 'warn';
839
874
  const label = match ? '✅' : '⚠️ VERSION MISMATCH';
840
875
  const msg = `[VERSION CHECK] ${label} server=${PKG_VERSION} extension=${extVersion}`;
841
876
  console.log(msg);
842
877
  logCDP('VERSION', msg);
843
878
  if (!match) {
879
+ // STRICT_VERSION=true(生产环境):版本不一致直接拒绝,避免老扩展与新 proxy 不兼容
880
+ if (process.env.STRICT_VERSION === 'true') {
881
+ console.log(` ↳ STRICT_VERSION=true,拒绝连接`);
882
+ logConnectionEvent('PLUGIN_VERSION_REJECTED', `server=${PKG_VERSION} extension=${extVersion}`);
883
+ ws.close(4002, `Version mismatch: server=${PKG_VERSION} extension=${extVersion}`);
884
+ return;
885
+ }
844
886
  console.log(` ↳ Run "cdp-tunnel update" or reload the extension to sync versions`);
845
887
  }
846
888
  getNamespace(ws).cachedBrowserVersion = null;
@@ -2101,10 +2143,25 @@ server.listen(PORT, '0.0.0.0');
2101
2143
 
2102
2144
  // v3.0 端口池启动(唯一模式)
2103
2145
  portPool = new PortPoolManager({
2104
- getPluginConnection: () => {
2146
+ getPluginConnection: (apiKey) => {
2147
+ // 鉴权模式:按 key 找对应的 plugin(一 key 一浏览器)
2148
+ if (apiKey) {
2149
+ for (const ws of pluginConnections) {
2150
+ if (ws.readyState === WebSocket.OPEN && ws.apiKey === apiKey) return ws;
2151
+ }
2152
+ return null;
2153
+ }
2154
+ // 无鉴权模式(本地开发):返回第一个 plugin
2105
2155
  for (const ws of pluginConnections) return ws;
2106
2156
  return null;
2107
2157
  },
2158
+ validateClientKey: (apiKey) => {
2159
+ // 无 SaaS 或未开启强制鉴权时放行(本地开发模式)
2160
+ if (!HAS_SAAS || process.env.REQUIRE_AUTH !== 'true') return true;
2161
+ // 开启强制鉴权:必须有有效 key
2162
+ if (!apiKey) return false;
2163
+ return !!validateApiKey(apiKey);
2164
+ },
2108
2165
  handlePluginUpgrade: (req, socket, head) => {
2109
2166
  wss.handleUpgrade(req, socket, head, (ws) => {
2110
2167
  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
 
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * API Key 管理工具(手动创建/列出/删除 key)
6
+ *
7
+ * 一 key = 一浏览器。创建后把带 key 的地址给用户填进扩展。
8
+ *
9
+ * 用法:
10
+ * node server/saas/key-manager.js create [name] 创建 key
11
+ * node server/saas/key-manager.js list 列出所有 key
12
+ * node server/saas/key-manager.js revoke <keyId> 吊销 key
13
+ *
14
+ * 创建后会输出一个地址,格式:
15
+ * ws://localhost:9221/plugin?key=ak_xxxxxxxx
16
+ * 上云后把 localhost 换成云域名。
17
+ */
18
+
19
+ const auth = require('./auth');
20
+
21
+ const command = process.argv[2];
22
+ const arg = process.argv[3];
23
+
24
+ // 用来管理 key 的内置用户(绕过注册系统)
25
+ const BUILTIN_USER = { id: 'builtin-admin', email: 'admin@local', displayName: 'Admin' };
26
+
27
+ function ensureBuiltinUser() {
28
+ const db = require('./db');
29
+ const existing = db.prepare('SELECT id FROM users WHERE id = ?').get(BUILTIN_USER.id);
30
+ if (!existing) {
31
+ db.prepare('INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)')
32
+ .run(BUILTIN_USER.id, BUILTIN_USER.email, 'builtin-no-login', BUILTIN_USER.displayName);
33
+ }
34
+ return BUILTIN_USER;
35
+ }
36
+
37
+ function main() {
38
+ try {
39
+ if (command === 'create' || !command) {
40
+ ensureBuiltinUser();
41
+ const name = arg || ('browser-' + Date.now().toString(36));
42
+ const keyInfo = auth.createApiKey(BUILTIN_USER.id, name);
43
+ const port = process.env.PORT || 9221;
44
+ const host = process.env.EXTERNAL_HOST || `localhost:${port}`;
45
+ console.log('\n✅ Key 创建成功\n');
46
+ console.log(` Key ID: ${keyInfo.keyId}`);
47
+ console.log(` 名称: ${name}`);
48
+ console.log(` Key: ${keyInfo.key}`);
49
+ console.log(`\n 扩展连接地址(填进扩展配置页):`);
50
+ console.log(` \x1b[32mws://${host}/plugin?key=${keyInfo.key}\x1b[0m`);
51
+ console.log(`\n CDP 客户端连接地址:`);
52
+ console.log(` \x1b[32mws://${host}/client?key=${keyInfo.key}\x1b[0m`);
53
+ console.log('');
54
+ return;
55
+ }
56
+
57
+ if (command === 'list') {
58
+ ensureBuiltinUser();
59
+ const keys = auth.listApiKeys(BUILTIN_USER.id);
60
+ if (keys.length === 0) {
61
+ console.log('\n(没有 key,用 create 创建)\n');
62
+ return;
63
+ }
64
+ console.log('\n=== API Keys ===\n');
65
+ keys.forEach(k => {
66
+ const status = k.active ? '✅' : '❌';
67
+ const lastUsed = k.last_used_at ? k.last_used_at : '从未';
68
+ console.log(` ${status} ${k.name}`);
69
+ console.log(` ID: ${k.id}`);
70
+ console.log(` 最后使用: ${lastUsed}`);
71
+ console.log(` 创建: ${k.created_at}`);
72
+ console.log('');
73
+ });
74
+ return;
75
+ }
76
+
77
+ if (command === 'revoke') {
78
+ if (!arg) {
79
+ console.log('用法: node key-manager.js revoke <keyId>');
80
+ process.exit(1);
81
+ }
82
+ ensureBuiltinUser();
83
+ auth.revokeApiKey(arg, BUILTIN_USER.id);
84
+ console.log(`\n✅ Key ${arg} 已吊销\n`);
85
+ return;
86
+ }
87
+
88
+ console.log('用法:');
89
+ console.log(' node server/saas/key-manager.js create [name] 创建 key');
90
+ console.log(' node server/saas/key-manager.js list 列出所有 key');
91
+ console.log(' node server/saas/key-manager.js revoke <keyId> 吊销 key');
92
+ } catch (e) {
93
+ console.error('错误:', e.message);
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ main();