@xcanwin/manyoyo 8.3.3 → 8.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/README.md +1 -1
- package/bin/manyoyo.js +111 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/browser-background.js +23 -0
- package/lib/container-env.sh +34 -0
- package/lib/container-exec.js +132 -0
- package/lib/container-id.js +11 -0
- package/lib/container-init.sh +40 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +615 -0
- package/lib/container-run.js +27 -3
- package/lib/container-state.js +467 -0
- package/lib/egress-denied.js +128 -0
- package/lib/egress-proxy.js +315 -0
- package/lib/egress-sidecar-main.js +112 -0
- package/lib/egress-sidecar.js +276 -0
- package/lib/env-text.js +62 -0
- package/lib/json5-text-edit.js +5 -5
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +269 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +137 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +506 -0
- package/lib/web/denied-notify.js +52 -0
- package/lib/web/index.html +81 -77
- package/lib/web/server.js +352 -135
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { isValidId } = require('./container-id');
|
|
5
|
+
|
|
6
|
+
// 容器网络策略:校验(唯一入口)+ 生成 nft 规则文本。
|
|
7
|
+
// nft 文本只由校验过的结构化数据生成:IP/CIDR 经 net.isIP + 前缀整数、端口是 1-65535 的整数、域名不进规则
|
|
8
|
+
// (域名只在过滤代理里匹配),所以任何用户字符串都拼不进规则。
|
|
9
|
+
const PRESETS = ['restricted', 'open', 'allowlist'];
|
|
10
|
+
const PROTOS = ['tcp', 'udp'];
|
|
11
|
+
const LIMITS = { domains: 200, rules: 100, host: 50, inbound: 50, deny: 100, expose: 20, portItems: 32 };
|
|
12
|
+
const DOMAIN_RE = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
13
|
+
|
|
14
|
+
// restricted / allowlist 默认禁止的私有与保留网段(公网放行)
|
|
15
|
+
const PRIVATE_V4 = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4'];
|
|
16
|
+
// 64:ff9b::/96(NAT64)里嵌的是 IPv4,可以映射到私有地址
|
|
17
|
+
const PRIVATE_V6 = ['fc00::/7', '64:ff9b::/96'];
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
throw new Error(message);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPort(value) {
|
|
24
|
+
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 端口表达式 "80" / "80-90" / "80,443,8000-8100" → 规范化字符串;空串表示不限端口(仅部分规则允许)。
|
|
29
|
+
*/
|
|
30
|
+
function normalizePorts(input, { required = true, label = '端口' } = {}) {
|
|
31
|
+
const text = String(input === undefined || input === null ? '' : input).replace(/\s+/g, '');
|
|
32
|
+
if (!text) {
|
|
33
|
+
if (required) fail(`${label}不能为空`);
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
const items = text.split(',');
|
|
37
|
+
if (items.length > LIMITS.portItems) fail(`${label}条目过多`);
|
|
38
|
+
const out = items.map(item => {
|
|
39
|
+
const m = /^(\d{1,5})(?:-(\d{1,5}))?$/.exec(item);
|
|
40
|
+
if (!m) fail(`${label}格式非法: ${String(input).slice(0, 40)}`);
|
|
41
|
+
const a = Number(m[1]);
|
|
42
|
+
const b = m[2] === undefined ? a : Number(m[2]);
|
|
43
|
+
if (!isPort(a) || !isPort(b) || b < a) fail(`${label}超出范围: ${item}`);
|
|
44
|
+
return a === b ? String(a) : `${a}-${b}`;
|
|
45
|
+
});
|
|
46
|
+
return out.join(',');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeProto(input) {
|
|
50
|
+
const proto = String(input === undefined || input === null || input === '' ? 'tcp' : input).toLowerCase();
|
|
51
|
+
if (!PROTOS.includes(proto)) fail(`协议只支持 tcp / udp: ${String(input).slice(0, 20)}`);
|
|
52
|
+
return proto;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** IP 或 CIDR,返回 {cidr, family};不接受主机名。 */
|
|
56
|
+
function normalizeCidr(input, label = 'CIDR') {
|
|
57
|
+
const text = String(input === undefined || input === null ? '' : input).trim();
|
|
58
|
+
const m = /^([0-9a-fA-F:.]+)(?:\/(\d{1,3}))?$/.exec(text);
|
|
59
|
+
if (!m) fail(`${label}格式非法: ${text.slice(0, 60)}`);
|
|
60
|
+
const family = net.isIP(m[1]);
|
|
61
|
+
if (!family) fail(`${label}不是合法 IP: ${text.slice(0, 60)}`);
|
|
62
|
+
const max = family === 4 ? 32 : 128;
|
|
63
|
+
let prefix = m[2] === undefined ? max : Number(m[2]);
|
|
64
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > max) fail(`${label}前缀超出范围: ${text.slice(0, 60)}`);
|
|
65
|
+
return { cidr: `${m[1].toLowerCase()}${m[2] === undefined ? '' : `/${prefix}`}`, family };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeDomain(input) {
|
|
69
|
+
const text = String(input === undefined || input === null ? '' : input).trim().toLowerCase();
|
|
70
|
+
if (text.length > 253 || !DOMAIN_RE.test(text)) fail(`域名非法: ${text.slice(0, 80)}`);
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeBind(input) {
|
|
75
|
+
const text = String(input === undefined || input === null || input === '' ? '127.0.0.1' : input).trim();
|
|
76
|
+
if (!net.isIP(text)) fail(`监听地址必须是 IP: ${text.slice(0, 60)}`);
|
|
77
|
+
return text.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asArray(value, max, label) {
|
|
81
|
+
if (value === undefined || value === null) return [];
|
|
82
|
+
if (!Array.isArray(value)) fail(`${label}必须是数组`);
|
|
83
|
+
if (value.length > max) fail(`${label}最多 ${max} 条`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function asObject(value, label) {
|
|
88
|
+
if (value === undefined || value === null) return {};
|
|
89
|
+
if (typeof value !== 'object' || Array.isArray(value)) fail(`${label}必须是对象`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultPolicy() {
|
|
94
|
+
return {
|
|
95
|
+
version: 1,
|
|
96
|
+
preset: 'restricted',
|
|
97
|
+
egress: { domains: [], rules: [] },
|
|
98
|
+
host: [],
|
|
99
|
+
peers: { inbound: [] },
|
|
100
|
+
deny: [],
|
|
101
|
+
expose: [],
|
|
102
|
+
autostartOnServe: false
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 校验并规范化策略;任何非法输入抛错(调用方返回 400)。
|
|
108
|
+
*/
|
|
109
|
+
function normalizePolicy(input) {
|
|
110
|
+
const src = asObject(input, '网络策略');
|
|
111
|
+
const policy = defaultPolicy();
|
|
112
|
+
if (src.preset !== undefined) {
|
|
113
|
+
if (!PRESETS.includes(src.preset)) fail(`preset 只支持 ${PRESETS.join(' / ')}`);
|
|
114
|
+
policy.preset = src.preset;
|
|
115
|
+
}
|
|
116
|
+
const egress = asObject(src.egress, 'egress');
|
|
117
|
+
const seenDomains = new Set();
|
|
118
|
+
asArray(egress.domains, LIMITS.domains, 'egress.domains').forEach(d => {
|
|
119
|
+
const domain = normalizeDomain(d);
|
|
120
|
+
if (!seenDomains.has(domain)) {
|
|
121
|
+
seenDomains.add(domain);
|
|
122
|
+
policy.egress.domains.push(domain);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
asArray(egress.rules, LIMITS.rules, 'egress.rules').forEach(rule => {
|
|
126
|
+
const r = asObject(rule, 'egress.rules[]');
|
|
127
|
+
const { cidr } = normalizeCidr(r.cidr, 'egress.rules.cidr');
|
|
128
|
+
policy.egress.rules.push({ cidr, ports: normalizePorts(r.ports, { required: false }), proto: normalizeProto(r.proto) });
|
|
129
|
+
});
|
|
130
|
+
asArray(src.host, LIMITS.host, 'host').forEach(entry => {
|
|
131
|
+
const e = asObject(entry, 'host[]');
|
|
132
|
+
policy.host.push({ ports: normalizePorts(e.ports, { label: '宿主机端口' }), proto: normalizeProto(e.proto) });
|
|
133
|
+
});
|
|
134
|
+
const peers = asObject(src.peers, 'peers');
|
|
135
|
+
asArray(peers.inbound, LIMITS.inbound, 'peers.inbound').forEach(entry => {
|
|
136
|
+
const e = asObject(entry, 'peers.inbound[]');
|
|
137
|
+
if (!isValidId(e.from)) fail('peers.inbound.from 必须是容器 id');
|
|
138
|
+
policy.peers.inbound.push({ from: e.from, ports: normalizePorts(e.ports, { label: '入站端口' }), proto: normalizeProto(e.proto) });
|
|
139
|
+
});
|
|
140
|
+
asArray(src.deny, LIMITS.deny, 'deny').forEach(entry => {
|
|
141
|
+
policy.deny.push({ cidr: normalizeCidr(asObject(entry, 'deny[]').cidr, 'deny.cidr').cidr });
|
|
142
|
+
});
|
|
143
|
+
const hostPorts = new Set();
|
|
144
|
+
asArray(src.expose, LIMITS.expose, 'expose').forEach(entry => {
|
|
145
|
+
const e = asObject(entry, 'expose[]');
|
|
146
|
+
const hostPort = Number(e.hostPort);
|
|
147
|
+
const port = Number(e.port);
|
|
148
|
+
if (!isPort(hostPort)) fail(`宿主机端口非法: ${String(e.hostPort).slice(0, 10)}`);
|
|
149
|
+
if (!isPort(port)) fail(`容器端口非法: ${String(e.port).slice(0, 10)}`);
|
|
150
|
+
const bind = normalizeBind(e.bind);
|
|
151
|
+
const key = `${bind}:${hostPort}`;
|
|
152
|
+
if (hostPorts.has(key)) fail(`重复的暴露端口: ${key}`);
|
|
153
|
+
hostPorts.add(key);
|
|
154
|
+
policy.expose.push({ bind, hostPort, port });
|
|
155
|
+
});
|
|
156
|
+
policy.autostartOnServe = src.autostartOnServe === true;
|
|
157
|
+
return policy;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- 域名匹配(过滤代理用) ----
|
|
161
|
+
|
|
162
|
+
/** pattern 为 `example.com`(只匹配自身)或 `*.example.com`(匹配任意子域,不含自身)。 */
|
|
163
|
+
function domainMatches(pattern, host) {
|
|
164
|
+
const h = String(host || '').toLowerCase().replace(/\.$/, '');
|
|
165
|
+
if (pattern.startsWith('*.')) return h.endsWith(pattern.slice(1)) && h.length > pattern.length - 1;
|
|
166
|
+
return h === pattern;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function domainAllowed(domains, host) {
|
|
170
|
+
return (domains || []).some(pattern => domainMatches(pattern, host));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- nft 生成 ----
|
|
174
|
+
|
|
175
|
+
function portSet(ports) {
|
|
176
|
+
const normalized = normalizePorts(ports);
|
|
177
|
+
return normalized.includes(',') ? `{ ${normalized.split(',').join(', ')} }` : normalized;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function addrExpr(direction, cidr, label) {
|
|
181
|
+
const { cidr: c, family } = normalizeCidr(cidr, label);
|
|
182
|
+
return `${family === 4 ? 'ip' : 'ip6'} ${direction} ${c}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function portClause(proto, ports) {
|
|
186
|
+
return ports ? ` ${normalizeProto(proto)} dport ${portSet(ports)}` : ` meta l4proto ${normalizeProto(proto)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cidrList(cidrs, family) {
|
|
190
|
+
return cidrs.map(c => normalizeCidr(c)).filter(c => c.family === family).map(c => c.cidr);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {object} policy normalizePolicy 的结果
|
|
195
|
+
* @param {object} endpoints 运行时解析出的地址(下发时现算,全部重新校验)
|
|
196
|
+
* @param {string[]} [endpoints.dns]
|
|
197
|
+
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo 必需端点、上游代理、过滤代理、Playwright、镜像、host 端口、出站 peer
|
|
198
|
+
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.peerIn] 允许进来的 peer
|
|
199
|
+
* @param {{daddr:string, saddr:string}[]} [endpoints.antiSpoof] 发往 daddr 的包来源必须是 saddr(过滤代理按来源 IP 认容器)
|
|
200
|
+
* @param {{subnet:string, gateway:string}|null} [endpoints.bridge]
|
|
201
|
+
* @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);open 预设只删表
|
|
202
|
+
*/
|
|
203
|
+
function buildNftRuleset(policy, endpoints = {}) {
|
|
204
|
+
const lines = ['add table inet manyoyo', 'delete table inet manyoyo'];
|
|
205
|
+
if (policy.preset === 'open') return `${lines.join('\n')}\n`;
|
|
206
|
+
|
|
207
|
+
const out = [' oif lo accept', ' ct state established,related accept'];
|
|
208
|
+
(endpoints.antiSpoof || []).forEach(item => {
|
|
209
|
+
out.push(` ${addrExpr('daddr', item.daddr, 'antiSpoof')} ${addrExpr('saddr', item.saddr, 'antiSpoof').replace(' saddr ', ' saddr != ')} drop`);
|
|
210
|
+
});
|
|
211
|
+
// 用户 deny 排在所有放行规则之前,才能真正“禁止”
|
|
212
|
+
if (policy.preset !== 'open') policy.deny.forEach(rule => out.push(` ${addrExpr('daddr', rule.cidr, 'deny')} reject`));
|
|
213
|
+
(endpoints.dns || []).forEach(ip => {
|
|
214
|
+
const { cidr, family } = normalizeCidr(ip, 'dns');
|
|
215
|
+
const prefix = family === 4 ? 'ip' : 'ip6';
|
|
216
|
+
out.push(` ${prefix} daddr ${cidr} udp dport 53 accept`);
|
|
217
|
+
out.push(` ${prefix} daddr ${cidr} tcp dport 53 accept`);
|
|
218
|
+
});
|
|
219
|
+
(endpoints.allow || []).forEach(item => {
|
|
220
|
+
out.push(` ${addrExpr('daddr', item.ip, 'allow')}${portClause(item.proto, item.ports)} accept`);
|
|
221
|
+
});
|
|
222
|
+
// egress.rules:直连放行的 IP 规则(restricted 下用来放行私有网段里的指定端点,allowlist 下是唯一的直连出口)
|
|
223
|
+
policy.egress.rules.forEach(rule => {
|
|
224
|
+
out.push(` ${addrExpr('daddr', rule.cidr, 'egress')}${portClause(rule.proto, rule.ports)} accept`);
|
|
225
|
+
});
|
|
226
|
+
if (policy.preset === 'allowlist') {
|
|
227
|
+
out.push(' reject');
|
|
228
|
+
} else {
|
|
229
|
+
// restricted:先放行用户 deny 之外的公网,再封私有网段
|
|
230
|
+
const v4 = cidrList(PRIVATE_V4, 4);
|
|
231
|
+
const v6 = cidrList(PRIVATE_V6, 6);
|
|
232
|
+
out.push(` ip daddr { ${v4.join(', ')} } reject`);
|
|
233
|
+
out.push(` ip6 daddr { ${v6.join(', ')} } reject`);
|
|
234
|
+
out.push(' meta l4proto { tcp, udp } ip6 daddr fe80::/10 reject');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const inn = [' iif lo accept', ' ct state established,related accept'];
|
|
238
|
+
(endpoints.peerIn || []).forEach(item => {
|
|
239
|
+
inn.push(` ${addrExpr('saddr', item.ip, 'peer')}${portClause(item.proto, item.ports)} accept`);
|
|
240
|
+
});
|
|
241
|
+
if (endpoints.bridge) {
|
|
242
|
+
const subnet = normalizeCidr(endpoints.bridge.subnet, 'bridge.subnet');
|
|
243
|
+
const gateway = normalizeCidr(endpoints.bridge.gateway, 'bridge.gateway');
|
|
244
|
+
if (subnet.family === 4 && gateway.family === 4) {
|
|
245
|
+
inn.push(` ip saddr ${subnet.cidr} ip saddr != ${gateway.cidr} ct state new reject`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lines.push('table inet manyoyo {');
|
|
250
|
+
lines.push(' chain output {', ' type filter hook output priority 0; policy accept;', ...out, ' }');
|
|
251
|
+
lines.push(' chain input {', ' type filter hook input priority 0; policy accept;', ...inn, ' }');
|
|
252
|
+
lines.push('}');
|
|
253
|
+
return `${lines.join('\n')}\n`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
PRESETS,
|
|
258
|
+
PRIVATE_V4,
|
|
259
|
+
PRIVATE_V6,
|
|
260
|
+
normalizePorts,
|
|
261
|
+
normalizeProto,
|
|
262
|
+
normalizeCidr,
|
|
263
|
+
normalizeDomain,
|
|
264
|
+
normalizePolicy,
|
|
265
|
+
defaultPolicy,
|
|
266
|
+
domainMatches,
|
|
267
|
+
domainAllowed,
|
|
268
|
+
buildNftRuleset
|
|
269
|
+
};
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// 浏览器指纹的唯一数据源:宿主机生成的配置与镜像内的 docker/res/playwright/* 都从这里生成。
|
|
4
4
|
// 原则是各项信号自洽:UA、Client Hints、平台、字体、WebGL 一律用浏览器真实值,不做自相矛盾的伪装;
|
|
5
|
-
//
|
|
5
|
+
// 只固定语言、时区、窗口形态与自动化痕迹。语言与时区由浏览器进程的原生环境变量(TZ / LANG / LANGUAGE)提供,
|
|
6
|
+
// 而不是 Playwright 的 contextOptions 模拟:后者只作用于页面,Worker 里还是系统值,会被检测站点识别为不一致。
|
|
6
7
|
|
|
7
8
|
const CONTAINER_CONFIG_DIR = '/run/manyoyo-playwright';
|
|
8
9
|
const CONTAINER_CONFIG_PATH = `${CONTAINER_CONFIG_DIR}/config.json`;
|
|
@@ -13,7 +14,7 @@ const DEFAULT_PROFILE = {
|
|
|
13
14
|
locale: 'en-US',
|
|
14
15
|
timezoneId: 'UTC',
|
|
15
16
|
navigatorPlatform: '',
|
|
16
|
-
disableWebRTC:
|
|
17
|
+
disableWebRTC: true
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
function resolveProfile(options = {}) {
|
|
@@ -22,13 +23,34 @@ function resolveProfile(options = {}) {
|
|
|
22
23
|
locale: text(options.locale, DEFAULT_PROFILE.locale),
|
|
23
24
|
timezoneId: text(options.timezoneId, DEFAULT_PROFILE.timezoneId),
|
|
24
25
|
navigatorPlatform: text(options.navigatorPlatform, DEFAULT_PROFILE.navigatorPlatform),
|
|
25
|
-
disableWebRTC: options.disableWebRTC === true
|
|
26
|
+
disableWebRTC: options.disableWebRTC === undefined ? DEFAULT_PROFILE.disableWebRTC : options.disableWebRTC === true
|
|
26
27
|
};
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// 浏览器进程的原生语言与时区环境变量;值会写进 shell 与 env 文件,所以格式必须严格。
|
|
31
|
+
// locale 只取“语言[-文字][-地区]”(兼容 en_US 写法,丢掉 -u- 之类的扩展),LANG 用 ll_RR.UTF-8
|
|
32
|
+
function buildProcessEnv(options = {}) {
|
|
33
|
+
const profile = resolveProfile(options);
|
|
34
|
+
const match = /^([A-Za-z]{2,3})(?:[-_][A-Za-z]{4})?(?:[-_]([A-Za-z]{2}|\d{3}))?(?:[-_][A-Za-z0-9]{1,8})*$/.exec(profile.locale);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(`locale 格式无效: ${profile.locale}`);
|
|
37
|
+
}
|
|
38
|
+
if (!/^[A-Za-z0-9_+/-]+$/.test(profile.timezoneId)) {
|
|
39
|
+
throw new Error(`timezoneId 格式无效: ${profile.timezoneId}`);
|
|
40
|
+
}
|
|
41
|
+
const language = match[1].toLowerCase();
|
|
42
|
+
const region = match[2] ? match[2].toUpperCase() : '';
|
|
43
|
+
const posix = region ? `${language}_${region}` : language;
|
|
44
|
+
return {
|
|
45
|
+
TZ: profile.timezoneId,
|
|
46
|
+
LANG: `${posix}.UTF-8`,
|
|
47
|
+
LANGUAGE: region ? `${posix}:${language}` : language
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 容器内 playwright-cli 包装脚本 source 的文件
|
|
52
|
+
function buildContainerEnvFile(options = {}) {
|
|
53
|
+
return Object.entries(buildProcessEnv(options)).map(([key, value]) => `export ${key}='${value}'\n`).join('');
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
// 宿主机的时区与系统语言,作为默认值
|
|
@@ -42,7 +64,8 @@ function buildLaunchArgs(options = {}, window = 'native') {
|
|
|
42
64
|
const profile = resolveProfile(options);
|
|
43
65
|
const args = [
|
|
44
66
|
`--lang=${profile.locale}`,
|
|
45
|
-
|
|
67
|
+
// 虚拟屏 1920x1080,fluxbox 底栏占 22px:窗口取工作区大小并贴在 (0,0),否则 Chrome 被整体顶上去(y=-21),顶部标签栏被截掉
|
|
68
|
+
...(window === 'virtual' ? ['--window-size=1920,1058', '--window-position=0,0'] : ['--start-maximized']),
|
|
46
69
|
'--disable-blink-features=AutomationControlled',
|
|
47
70
|
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
|
|
48
71
|
];
|
|
@@ -53,36 +76,23 @@ function buildLaunchArgs(options = {}, window = 'native') {
|
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
// 有头浏览器不设置 viewport / screen,让窗口尺寸与屏幕尺寸像真实窗口一样不同
|
|
56
|
-
function buildContextOptions(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'Accept-Language': acceptLanguage(profile.locale)
|
|
64
|
-
}
|
|
65
|
-
};
|
|
79
|
+
function buildContextOptions() {
|
|
80
|
+
return { viewport: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 只有 navigatorPlatform(用户显式设置)或 disableWebRTC(默认开启,STUN 会暴露本机公网 IP)生效时才会改写页面;都不需要时什么都不改(每个被改写的 getter 都可能被检测站点当作 lie)
|
|
84
|
+
function needsInitScript(profile) {
|
|
85
|
+
return Boolean(profile.navigatorPlatform) || profile.disableWebRTC;
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
function buildInitScript(options = {}) {
|
|
69
89
|
const profile = resolveProfile(options);
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
if (!needsInitScript(profile)) {
|
|
91
|
+
return "'use strict';\n";
|
|
92
|
+
}
|
|
72
93
|
const lines = [
|
|
73
94
|
"'use strict';",
|
|
74
|
-
'(function () {'
|
|
75
|
-
' try {',
|
|
76
|
-
' const toolbar = 87;',
|
|
77
|
-
' const patch = (name, fallback, getter) => {',
|
|
78
|
-
' const original = Object.getOwnPropertyDescriptor(window, name);',
|
|
79
|
-
' if (!original || !original.get) return;',
|
|
80
|
-
' const real = original.get;',
|
|
81
|
-
' Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });',
|
|
82
|
-
' };',
|
|
83
|
-
" patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);",
|
|
84
|
-
" patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);",
|
|
85
|
-
' } catch (_) {}'
|
|
95
|
+
'(function () {'
|
|
86
96
|
];
|
|
87
97
|
if (profile.navigatorPlatform) {
|
|
88
98
|
lines.push(
|
|
@@ -117,16 +127,16 @@ function buildInitScript(options = {}) {
|
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
// 容器内 playwright-cli 读取的配置。
|
|
120
|
-
// default: 容器内 Xvfb
|
|
130
|
+
// default: 容器内 Xvfb 里的有头 Google Chrome;ws: 附着宿主机 launch-server;cdp: 附着用户自己的 Chrome(不注入指纹)
|
|
121
131
|
function buildContainerConfig(kind, options = {}) {
|
|
122
132
|
const base = { outputDir: CONTAINER_OUTPUT_DIR };
|
|
123
133
|
if (kind === 'cdp') {
|
|
124
134
|
return { ...base, browser: { cdpEndpoint: options.endpoint, cdpTimeout: 60000 } };
|
|
125
135
|
}
|
|
126
|
-
const fingerprint = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
136
|
+
const fingerprint = { contextOptions: buildContextOptions() };
|
|
137
|
+
if (needsInitScript(resolveProfile(options))) {
|
|
138
|
+
fingerprint.initScript = [CONTAINER_INIT_SCRIPT_PATH];
|
|
139
|
+
}
|
|
130
140
|
if (kind === 'ws') {
|
|
131
141
|
// launch-server 不预创建 context,必须 isolated 才会自建
|
|
132
142
|
return { ...base, browser: { remoteEndpoint: options.endpoint, isolated: true, ...fingerprint } };
|
|
@@ -134,12 +144,12 @@ function buildContainerConfig(kind, options = {}) {
|
|
|
134
144
|
return {
|
|
135
145
|
...base,
|
|
136
146
|
browser: {
|
|
137
|
-
// 容器内是 root,Chromium 不允许 root 开沙箱
|
|
138
|
-
chromiumSandbox: false,
|
|
139
147
|
browserName: 'chromium',
|
|
140
148
|
...fingerprint,
|
|
141
149
|
launchOptions: {
|
|
142
|
-
channel: '
|
|
150
|
+
channel: 'chrome',
|
|
151
|
+
// 容器内是 root,Chrome 不允许 root 开沙箱;必须写在 launchOptions 里才生效
|
|
152
|
+
chromiumSandbox: false,
|
|
143
153
|
headless: false,
|
|
144
154
|
args: buildLaunchArgs(options, 'virtual')
|
|
145
155
|
}
|
|
@@ -148,16 +158,20 @@ function buildContainerConfig(kind, options = {}) {
|
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
// 宿主机 playwright launch-server 的配置
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
channel
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
// useChrome 为 false(宿主机没装 Google Chrome)时不写 channel,退回 patchright 自己下载的 Chromium。
|
|
162
|
+
// extensionPaths:官方 Google Chrome 从 M137 起忽略 --load-extension,扩展由 playwright-server.js 在启动后经 CDP
|
|
163
|
+
// Extensions.loadUnpacked 加载(需要 --enable-unsafe-extension-debugging,所以只在有扩展时才加);该字段会在 launchServer 前被取走
|
|
164
|
+
function buildServerConfig({ host, port, wsPath, headless = false, window = 'native', chromiumSandbox = true, useChrome = true, extensionPaths = [], ...profileOptions }) {
|
|
165
|
+
const config = { host, port, wsPath, headless, chromiumSandbox };
|
|
166
|
+
if (useChrome) {
|
|
167
|
+
config.channel = 'chrome';
|
|
168
|
+
}
|
|
169
|
+
config.args = buildLaunchArgs(profileOptions, window);
|
|
170
|
+
if (extensionPaths.length > 0) {
|
|
171
|
+
config.args.push('--enable-unsafe-extension-debugging');
|
|
172
|
+
config.extensionPaths = extensionPaths;
|
|
173
|
+
}
|
|
174
|
+
return config;
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
module.exports = {
|
|
@@ -167,7 +181,8 @@ module.exports = {
|
|
|
167
181
|
DEFAULT_PROFILE,
|
|
168
182
|
resolveProfile,
|
|
169
183
|
detectHostProfile,
|
|
170
|
-
|
|
184
|
+
buildProcessEnv,
|
|
185
|
+
buildContainerEnvFile,
|
|
171
186
|
buildLaunchArgs,
|
|
172
187
|
buildContextOptions,
|
|
173
188
|
buildInitScript,
|
|
@@ -194,14 +194,6 @@ function resolveExtensionInputs(pluginRoot, { extensionPaths = [], extensionName
|
|
|
194
194
|
return resolveExtensionPaths([...extensionPaths, ...named]);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
function buildExtensionLaunchArgs(extensionPaths) {
|
|
198
|
-
if (extensionPaths.length === 0) {
|
|
199
|
-
return [];
|
|
200
|
-
}
|
|
201
|
-
const joined = extensionPaths.join(',');
|
|
202
|
-
return [`--disable-extensions-except=${joined}`, `--load-extension=${joined}`];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
197
|
// 容器里看到的扩展目录:${CONTAINER_EXTENSION_ROOT}/ext-<序号>-<目录名>
|
|
206
198
|
function buildContainerExtensionMounts(extensionPaths) {
|
|
207
199
|
const containerPaths = [];
|
|
@@ -289,7 +281,6 @@ module.exports = {
|
|
|
289
281
|
CONTAINER_EXTENSION_ROOT,
|
|
290
282
|
buildContainerExtensionMounts,
|
|
291
283
|
buildCrxUrl,
|
|
292
|
-
buildExtensionLaunchArgs,
|
|
293
284
|
convertManifestV2ToV3,
|
|
294
285
|
crxZipOffset,
|
|
295
286
|
downloadExtensions,
|
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// 宿主机(或 vnc 容器内)的 Playwright 浏览器服务:用
|
|
3
|
+
// 宿主机(或 vnc 容器内)的 Playwright 浏览器服务:用 patchright-core 启动有头浏览器(Google Chrome 优先)并开放 WebSocket。
|
|
4
4
|
// 用法:node playwright-server.js <playwright-core 目录> <配置文件>,配置为 chromium.launchServer 的选项。
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
|
|
8
|
+
// 官方 Google Chrome 忽略 --load-extension,改用 CDP Extensions.loadUnpacked;加载后的扩展在连接断开后仍保留
|
|
9
|
+
async function loadExtensions(chromium, endpoint, extensionPaths) {
|
|
10
|
+
const browser = await chromium.connect(endpoint);
|
|
11
|
+
try {
|
|
12
|
+
const cdp = await browser.newBrowserCDPSession();
|
|
13
|
+
for (const extensionPath of extensionPaths) {
|
|
14
|
+
try {
|
|
15
|
+
await cdp.send('Extensions.loadUnpacked', { path: extensionPath });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(`扩展加载失败 ${extensionPath}: ${String(error && error.message || error).split('\n')[0]}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
} finally {
|
|
21
|
+
await browser.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
8
25
|
async function main() {
|
|
9
26
|
const [corePath, configPath] = process.argv.slice(2);
|
|
10
|
-
const options = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
27
|
+
const { extensionPaths = [], ...options } = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
11
28
|
const { chromium } = require(corePath);
|
|
12
29
|
const server = await chromium.launchServer(options);
|
|
30
|
+
if (extensionPaths.length > 0) {
|
|
31
|
+
await loadExtensions(chromium, server.wsEndpoint(), extensionPaths);
|
|
32
|
+
}
|
|
13
33
|
const shutdown = () => server.close().finally(() => process.exit(0));
|
|
14
34
|
process.on('SIGTERM', shutdown);
|
|
15
35
|
process.on('SIGINT', shutdown);
|