@xcanwin/manyoyo 8.3.2 → 8.4.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.
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  // Playwright 插件:容器内的浏览器怎么来,只有 4 种模式、同一时间只有一个。
4
- // default 容器内 Xvfb 里的有头浏览器(无需宿主机进程)
5
- // headed 宿主机有窗口的浏览器
4
+ // default 容器内 Xvfb 里的有头 Google Chrome(无需宿主机进程)
5
+ // headed 宿主机有窗口的浏览器(优先用本机 Google Chrome)
6
6
  // chrome 用户正在使用的 Chrome(经本机中继)
7
7
  // vnc 独立容器里的浏览器,用 noVNC 网页观看
8
8
  // 宿主机目录 current/ 以目录方式只读挂进容器的 /run/manyoyo-playwright/,里面的 config.json 一律原子替换,
@@ -106,6 +106,19 @@ function defaultListenHost(platform = process.platform) {
106
106
  return platform === 'darwin' ? '127.0.0.1' : '0.0.0.0';
107
107
  }
108
108
 
109
+ // Google Chrome 在各平台的标准安装位置(patchright 的 channel: 'chrome' 也在这些位置查找)
110
+ function googleChromeCandidates(platform = process.platform, env = process.env) {
111
+ if (platform === 'darwin') {
112
+ return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
113
+ }
114
+ if (platform === 'win32') {
115
+ return ['PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA']
116
+ .filter(name => env[name])
117
+ .map(name => path.join(env[name], 'Google', 'Chrome', 'Application', 'chrome.exe'));
118
+ }
119
+ return ['/opt/google/chrome/chrome'];
120
+ }
121
+
109
122
  // headed 的浏览器服务必须监听 0.0.0.0:playwright 只绑 loopback 时会按 Host 头做 DNS 重绑定防护,
110
123
  // 容器用 host.containers.internal 访问会被 403(macOS 上实测);监听 0.0.0.0 则不检查 Host,靠 token 保护
111
124
  function headedListenHost() {
@@ -171,7 +184,7 @@ class PlaywrightPlugin {
171
184
  locale: String(merged.locale || '').trim() || host.locale,
172
185
  timezoneId: String(merged.timezoneId || '').trim() || host.timezoneId,
173
186
  navigatorPlatform: String(merged.navigatorPlatform || '').trim(),
174
- disableWebRTC: merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true',
187
+ disableWebRTC: merged.disableWebRTC === undefined ? fingerprint.DEFAULT_PROFILE.disableWebRTC : (merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true'),
175
188
  devtoolsActivePortPath: String(merged.devtoolsActivePortPath || '').trim(),
176
189
  extensionProdversion: String(merged.extensionProdversion || '').trim() || '132.0.0.0'
177
190
  };
@@ -276,8 +289,10 @@ class PlaywrightPlugin {
276
289
  this.ensureDirs();
277
290
  const config = `${JSON.stringify(this.containerConfigFor(state, runtimeCommand), null, 4)}\n`;
278
291
  const initScript = fingerprint.buildInitScript(this.profile());
292
+ const envFile = fingerprint.buildContainerEnvFile(this.profile());
279
293
  const configPath = path.join(this.currentDir, 'config.json');
280
294
  const initPath = path.join(this.currentDir, 'stealth.init.js');
295
+ const envPath = path.join(this.currentDir, 'env');
281
296
  const same = (file, content) => {
282
297
  try {
283
298
  return fs.readFileSync(file, 'utf8') === content;
@@ -288,6 +303,9 @@ class PlaywrightPlugin {
288
303
  if (!same(initPath, initScript)) {
289
304
  writeConfigFileSecure(initPath, initScript);
290
305
  }
306
+ if (!same(envPath, envFile)) {
307
+ writeConfigFileSecure(envPath, envFile);
308
+ }
291
309
  if (!same(configPath, config)) {
292
310
  writeConfigFileSecure(configPath, config);
293
311
  }
@@ -395,27 +413,42 @@ class PlaywrightPlugin {
395
413
  };
396
414
  }
397
415
 
416
+ // 浏览器服务与探测用 patchright-core(去掉 CDP Runtime 痕迹的开源补丁版),
417
+ // 版本与镜像内 @playwright/cli 的 playwright-core 同 minor,否则容器内 client 连宿主机服务会被 428 拒绝
398
418
  corePath() {
399
- const cliPackage = require.resolve('@playwright/cli/package.json');
400
- return path.dirname(require.resolve('playwright-core/package.json', { paths: [path.dirname(cliPackage)] }));
419
+ return path.dirname(require.resolve('patchright-core/package.json'));
401
420
  }
402
421
 
403
422
  coreCli() {
404
423
  return path.join(this.corePath(), 'cli.js');
405
424
  }
406
425
 
407
- // 浏览器与探测都用 @playwright/cli 自带的 playwright-core,保证与容器内版本一致
408
426
  installCommand(args) {
409
427
  return `node ${shellValue(this.coreCli())} ${args}`;
410
428
  }
411
429
 
412
- ensureBrowserInstalled() {
430
+ // 宿主机是否装了 Google Chrome;不会替用户安装(需要 sudo)
431
+ hasGoogleChrome() {
432
+ return googleChromeCandidates().some(file => fs.existsSync(file));
433
+ }
434
+
435
+ installChromium() {
413
436
  const result = spawnSync(process.execPath, [this.coreCli(), 'install', '--no-shell', 'chromium'], { stdio: 'inherit' });
414
437
  if (result.status !== 0) {
415
438
  throw new Error(`浏览器安装失败,请手动执行: ${this.installCommand('install --no-shell chromium')}`);
416
439
  }
417
440
  }
418
441
 
442
+ // 返回是否使用 Google Chrome;没有时退回 patchright 的 Chromium 并提示
443
+ ensureBrowserInstalled() {
444
+ if (this.hasGoogleChrome()) {
445
+ return true;
446
+ }
447
+ this.out('[up] 未检测到 Google Chrome,使用 Chromium,部分检测站点会识别为 Chromium。安装 Google Chrome 后重新 up 即可。');
448
+ this.installChromium();
449
+ return false;
450
+ }
451
+
419
452
  // ---------- 探测 ----------
420
453
 
421
454
  probe(kind, endpoint, timeout = PROBE_TIMEOUT_MS) {
@@ -454,10 +487,10 @@ class PlaywrightPlugin {
454
487
 
455
488
  // ---------- 进程管理 ----------
456
489
 
457
- spawnDetached(command, args, logPath) {
490
+ spawnDetached(command, args, logPath, env = process.env) {
458
491
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
459
492
  const logFd = fs.openSync(logPath, 'a', 0o600);
460
- const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd] });
493
+ const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd], env });
461
494
  fs.closeSync(logFd);
462
495
  child.unref();
463
496
  return child;
@@ -538,8 +571,9 @@ class PlaywrightPlugin {
538
571
  if (!(await this.portPreflight('headed', port))) {
539
572
  return 1;
540
573
  }
574
+ let useChrome;
541
575
  try {
542
- this.ensureBrowserInstalled();
576
+ useChrome = this.ensureBrowserInstalled();
543
577
  } catch (error) {
544
578
  return this.startFailure('headed', error.message);
545
579
  }
@@ -553,21 +587,24 @@ class PlaywrightPlugin {
553
587
  port,
554
588
  wsPath: `/${token}`,
555
589
  chromiumSandbox,
556
- extensionArgs: extensions.buildExtensionLaunchArgs(extensionPaths),
590
+ useChrome,
591
+ extensionPaths,
557
592
  ...this.profile()
558
593
  }), null, 4)}\n`);
559
594
  fs.rmSync(this.logPath(), { force: true });
595
+ // 语言与时区放进浏览器进程的原生环境(合并而不是替换 process.env,代理等变量要保留)
560
596
  child = this.spawnDetached(process.execPath,
561
- [path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath());
597
+ [path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath(),
598
+ { ...process.env, ...fingerprint.buildProcessEnv(this.profile()) });
562
599
  if (await this.waitForPort(port, child)) {
563
600
  break;
564
601
  }
565
602
  await this.killGroup(child.pid, { verify: false });
566
603
  if (!chromiumSandbox || !tailText(this.logPath(), 200).includes('No usable sandbox')) {
567
604
  return this.startFailure('headed', '浏览器服务没有启动成功。',
568
- `${tailText(this.logPath(), 20)}\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`);
605
+ `${tailText(this.logPath(), 20)}${useChrome ? '' : `\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`}`);
569
606
  }
570
- this.out('[up] 系统不允许非特权用户命名空间,Chromium 沙箱不可用,改为无沙箱启动。');
607
+ this.out('[up] 系统不允许非特权用户命名空间,浏览器沙箱不可用,改为无沙箱启动。');
571
608
  }
572
609
  return { mode: 'headed', port, pid: child.pid, listenHost: headedListenHost(), extensionPaths };
573
610
  }
@@ -669,12 +706,13 @@ class PlaywrightPlugin {
669
706
  wsPath: `/${token}`,
670
707
  window: 'virtual',
671
708
  chromiumSandbox: false,
672
- extensionArgs: extensions.buildExtensionLaunchArgs(mounts.containerPaths),
709
+ extensionPaths: mounts.containerPaths,
673
710
  ...this.profile()
674
711
  }), null, 4)}\n`);
675
712
  const envFile = path.join(mountDir, 'env');
676
713
  const noProxy = appendNoProxy(process.env.NO_PROXY ?? process.env.no_proxy);
677
- writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n`);
714
+ const nativeEnv = Object.entries(fingerprint.buildProcessEnv(this.profile())).map(([key, value]) => `${key}=${value}\n`).join('');
715
+ writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n${nativeEnv}`);
678
716
  this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
679
717
  const listenHost = defaultListenHost();
680
718
  const args = [
@@ -917,5 +955,6 @@ module.exports = {
917
955
  appendNoProxy,
918
956
  connectHostFor,
919
957
  defaultListenHost,
958
+ googleChromeCandidates,
920
959
  headedListenHost
921
960
  };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const net = require('net');
5
+ const os = require('os');
6
+ const { buildExecArgs } = require('./container-exec');
7
+
8
+ // 运行中暴露容器端口:serve 在宿主机监听 bind:hostPort,每个连接起一个 `exec -i <容器> socat STDIO TCP:127.0.0.1:<端口>` 双向 pipe。
9
+ // 不需要重启容器、不依赖容器的网络模式(slirp4netns / bridge / pasta 都一样)。
10
+ const MAX_CONNECTIONS_PER_FORWARD = 64;
11
+
12
+ // 容器里优先 socat,没有就用 node 兜底;端口是校验过的整数
13
+ function containerCommand(port) {
14
+ const p = Number(port);
15
+ if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`端口非法: ${port}`);
16
+ const nodeScript = `const s=require('net').connect(${p},'127.0.0.1');process.stdin.pipe(s);s.pipe(process.stdout);s.on('error',()=>process.exit(1));s.on('close',()=>process.exit(0))`;
17
+ return ['/bin/sh', '-c', `if command -v socat >/dev/null 2>&1; then exec socat STDIO TCP:127.0.0.1:${p}; else exec node -e "${nodeScript}"; fi`];
18
+ }
19
+
20
+ function keyOf(id, bind, hostPort) {
21
+ return `${id}|${bind}:${hostPort}`;
22
+ }
23
+
24
+ function createPortForwarder(options) {
25
+ const command = options.command;
26
+ const env = options.env ? { ...process.env, ...options.env } : process.env;
27
+ const homeDir = options.homeDir || os.homedir();
28
+ const resolveName = options.resolveName;
29
+ const warn = options.warn || (() => {});
30
+ const forwards = new Map(); // key → { id, bind, hostPort, port, server, sockets:Set, active }
31
+
32
+ function handleConnection(forward, socket) {
33
+ if (forward.active >= MAX_CONNECTIONS_PER_FORWARD) {
34
+ socket.destroy();
35
+ return;
36
+ }
37
+ const name = resolveName(forward.id);
38
+ if (!name) {
39
+ // 容器(状态目录)已被删:这个转发没有意义了,释放宿主机端口
40
+ closeForward(keyOf(forward.id, forward.bind, forward.hostPort));
41
+ socket.destroy();
42
+ return;
43
+ }
44
+ forward.active += 1;
45
+ forward.sockets.add(socket);
46
+ const built = buildExecArgs({ homeDir, dockerExecArgs: () => '' }, name, {
47
+ interactive: true,
48
+ withEnv: false,
49
+ command: containerCommand(forward.port)
50
+ });
51
+ const child = spawn(command, built.args, { env, stdio: ['pipe', 'pipe', 'ignore'] });
52
+ const finish = () => {
53
+ if (forward.sockets.delete(socket)) forward.active -= 1;
54
+ built.cleanup();
55
+ socket.destroy();
56
+ if (!child.killed) child.kill('SIGTERM');
57
+ };
58
+ socket.on('error', finish);
59
+ socket.on('close', finish);
60
+ child.on('error', finish);
61
+ child.on('close', finish);
62
+ child.stdin.on('error', () => {});
63
+ child.stdout.on('error', () => {});
64
+ socket.pipe(child.stdin);
65
+ child.stdout.pipe(socket);
66
+ }
67
+
68
+ function open({ id, bind, hostPort, port }) {
69
+ const key = keyOf(id, bind, hostPort);
70
+ if (forwards.has(key)) return Promise.resolve();
71
+ return new Promise((resolve, reject) => {
72
+ const forward = { id, bind, hostPort, port, sockets: new Set(), active: 0, server: null };
73
+ const server = net.createServer(socket => handleConnection(forward, socket));
74
+ forward.server = server;
75
+ server.once('error', error => {
76
+ reject(error.code === 'EADDRINUSE'
77
+ ? new Error(`宿主机端口 ${bind}:${hostPort} 已被占用`)
78
+ : error);
79
+ });
80
+ server.listen(hostPort, bind, () => {
81
+ server.removeAllListeners('error');
82
+ server.on('error', error => warn(`端口转发 ${bind}:${hostPort} 出错: ${error.message}`));
83
+ forwards.set(key, forward);
84
+ resolve();
85
+ });
86
+ });
87
+ }
88
+
89
+ function closeForward(key) {
90
+ const forward = forwards.get(key);
91
+ if (!forward) return;
92
+ forwards.delete(key);
93
+ forward.server.close();
94
+ forward.sockets.forEach(socket => socket.destroy());
95
+ }
96
+
97
+ function close(id, bind, hostPort) {
98
+ closeForward(keyOf(id, bind, hostPort));
99
+ }
100
+
101
+ function closeFor(id) {
102
+ [...forwards.keys()].filter(key => key.startsWith(`${id}|`)).forEach(closeForward);
103
+ }
104
+
105
+ function closeAll() {
106
+ [...forwards.keys()].forEach(closeForward);
107
+ }
108
+
109
+ function list(id) {
110
+ return [...forwards.values()].filter(f => f.id === id).map(f => ({ bind: f.bind, hostPort: f.hostPort, port: f.port }));
111
+ }
112
+
113
+ // 让转发列表与策略一致:多的关、少的开;单条失败只警告
114
+ async function sync(id, expose) {
115
+ const want = new Map(expose.map(e => [keyOf(id, e.bind, e.hostPort), e]));
116
+ [...forwards.keys()].filter(key => key.startsWith(`${id}|`) && !want.has(key)).forEach(closeForward);
117
+ const failures = [];
118
+ for (const [key, entry] of want) {
119
+ const existing = forwards.get(key);
120
+ if (existing && existing.port === entry.port) continue;
121
+ if (existing) closeForward(key);
122
+ try {
123
+ await open({ id, ...entry });
124
+ } catch (e) {
125
+ failures.push(e.message);
126
+ warn(e.message);
127
+ }
128
+ }
129
+ return failures;
130
+ }
131
+
132
+ return { open, close, closeFor, closeAll, list, sync };
133
+ }
134
+
135
+ module.exports = { createPortForwarder, containerCommand, MAX_CONNECTIONS_PER_FORWARD };
package/lib/web/AGENTS.md CHANGED
@@ -21,6 +21,10 @@
21
21
  - 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
22
22
  - 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
23
23
 
24
+ ## 容器管理接口
25
+
26
+ `lib/web/container-manage.js`(路由工厂,由 `handleWebApi` 追加进路由表):`/api/containers/:name/{env,autostart,autostart/run,autostart/log,network,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag)防止覆盖容器内的修改;放开类改动(`open`、非 loopback 暴露)服务端也要求 `confirmRisk:true`。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;过滤代理 `ensureWebEgressProxy` 懒启动。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
27
+
24
28
  ## lib/web/index.html
25
29
 
26
30
  前端构建产物(`npm run build:web`,已 `.gitignore`),由 `loadIndexHtml()` 在每次请求时读取,`/` 与登录页 `/auth/login` 共用。服务端不托管其它前端静态资源,别往 `lib/web/` 里放散装 js/css。