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.
- package/extension-new/config-page-preview.html +5 -0
- package/extension-new/config-page.js +15 -4
- package/extension-new/manifest.json +1 -1
- package/extension-new/utils/config.js +1 -1
- package/package.json +1 -1
- package/server/modules/config.js +5 -0
- package/server/modules/port-pool.js +106 -13
- package/server/proxy-server.js +61 -4
- package/server/saas/auth.js +1 -1
- package/server/saas/key-manager.js +98 -0
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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() {
|
package/package.json
CHANGED
package/server/modules/config.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
400
|
-
//
|
|
401
|
-
|
|
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
|
-
//
|
|
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
|
}
|
package/server/proxy-server.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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
|
|
|
@@ -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();
|