@xcanwin/manyoyo 8.4.0 → 8.5.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.
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { createEgressProxy } = require('./egress-proxy');
6
+ const { createRecorder } = require('./egress-denied');
7
+
8
+ // 过滤代理 sidecar 容器里的入口(宿主机把这几个文件只读挂进来,见 egress-sidecar.js)。
9
+ // 数据目录(只读):clients.json(来源 IP → 容器 id + 策略,宿主机在每次下发规则时原子替换)、upstream.txt(可选,上游代理);
10
+ // 拒绝记录目录(读写):<容器 id>.jsonl。
11
+ const CLIENTS_FILE = 'clients.json';
12
+ const UPSTREAM_FILE = 'upstream.txt';
13
+ const RELOAD_CHECK_MS = 500;
14
+
15
+ function loadClients(file) {
16
+ try {
17
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
18
+ const clients = parsed && typeof parsed.clients === 'object' && parsed.clients ? parsed.clients : {};
19
+ return new Map(Object.entries(clients));
20
+ } catch (e) {
21
+ return new Map(); // 读不到 / 损坏:谁都不认识,全部 403(失败即关闭)
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {object} options
27
+ * @param {string} options.dataDir
28
+ * @param {string} options.deniedDir
29
+ * @param {number} options.port
30
+ * @param {string} [options.host]
31
+ * @param {(host: string) => Promise<string[]>} [options.lookup]
32
+ */
33
+ async function start(options) {
34
+ const clientsFile = path.join(options.dataDir, CLIENTS_FILE);
35
+ // 上游代理:upstream.txt 变了就热更新(CLI 与 serve 的环境可能不同,不能为此重建容器)
36
+ const upstreamFile = path.join(options.dataDir, UPSTREAM_FILE);
37
+ let upstream = '';
38
+ let upstreamSig = '';
39
+ let upstreamCheckedAt = 0;
40
+ const getUpstream = () => {
41
+ const nowMs = Date.now();
42
+ if (nowMs - upstreamCheckedAt < RELOAD_CHECK_MS) return upstream;
43
+ upstreamCheckedAt = nowMs;
44
+ try {
45
+ const stat = fs.statSync(upstreamFile);
46
+ const sig = `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
47
+ if (sig !== upstreamSig) {
48
+ upstreamSig = sig;
49
+ upstream = fs.readFileSync(upstreamFile, 'utf-8').trim();
50
+ }
51
+ } catch (e) {
52
+ upstreamSig = '';
53
+ upstream = '';
54
+ }
55
+ return upstream;
56
+ };
57
+
58
+ let clients = loadClients(clientsFile);
59
+ let signature = '';
60
+ let checkedAt = 0;
61
+ const refresh = () => {
62
+ const nowMs = Date.now();
63
+ if (nowMs - checkedAt < RELOAD_CHECK_MS) return;
64
+ checkedAt = nowMs;
65
+ try {
66
+ const stat = fs.statSync(clientsFile);
67
+ const next = `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
68
+ if (next !== signature) {
69
+ signature = next;
70
+ clients = loadClients(clientsFile);
71
+ }
72
+ } catch (e) {
73
+ signature = '';
74
+ clients = new Map();
75
+ }
76
+ };
77
+
78
+ const recorder = createRecorder({ dir: options.deniedDir });
79
+ const proxy = createEgressProxy({
80
+ upstream: getUpstream,
81
+ lookup: options.lookup,
82
+ getClient: ip => {
83
+ refresh();
84
+ const entry = clients.get(ip);
85
+ return entry ? { ...entry } : null;
86
+ },
87
+ onDenied: event => recorder.record(event)
88
+ });
89
+ const port = await proxy.start({ host: options.host || '0.0.0.0', port: options.port });
90
+ return {
91
+ port,
92
+ stop: async () => { recorder.stop(); await proxy.stop(); }
93
+ };
94
+ }
95
+
96
+ if (require.main === module) {
97
+ start({
98
+ dataDir: process.env.EGRESS_DATA_DIR || '/data',
99
+ deniedDir: process.env.EGRESS_DENIED_DIR || '/denied',
100
+ port: Number(process.env.EGRESS_PORT) || 3128
101
+ }).then(server => {
102
+ process.stdout.write(`egress-proxy ready ${server.port}\n`);
103
+ const shutdown = () => server.stop().finally(() => process.exit(0));
104
+ process.on('SIGTERM', shutdown);
105
+ process.on('SIGINT', shutdown);
106
+ }).catch(error => {
107
+ process.stderr.write(`egress-proxy failed: ${error.message}\n`);
108
+ process.exit(1);
109
+ });
110
+ }
111
+
112
+ module.exports = { start, CLIENTS_FILE, UPSTREAM_FILE };
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const net = require('net');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { writeConfigFileSecure } = require('./secure-file');
9
+
10
+ // 域名白名单的强制执行点:manyoyo 网络里的一个专用小容器(sidecar),里面用镜像自带的 node 跑 egress-proxy。
11
+ // - 容器按“来源 IP”识别:宿主机在每次下发规则时把「容器 IP → 容器 id + 策略」写进 clients.json(只读挂入),
12
+ // 认不出的来源一律 403;宿主机上不监听任何端口,同机其他用户无法占用或冒充。
13
+ // - 固定 IP(--ip):重启后不变,容器的代理地址与 nft 规则才能稳定。
14
+ // - sidecar 只读挂入程序文件和数据目录,只有拒绝记录目录可写;丢掉全部能力。
15
+ const SIDECAR_PORT = 3128;
16
+ const APP_FILES = ['egress-sidecar-main.js', 'egress-proxy.js', 'egress-denied.js', 'network-policy.js', 'container-id.js'];
17
+ const READY_TIMEOUT_MS = 15000;
18
+
19
+ function egressDir(homeDir) {
20
+ return path.join(homeDir, '.manyoyo', 'egress');
21
+ }
22
+
23
+ function egressPaths(homeDir) {
24
+ const dir = egressDir(homeDir);
25
+ return { dir, app: path.join(dir, 'app'), data: path.join(dir, 'data'), denied: path.join(dir, 'denied') };
26
+ }
27
+
28
+ function homeHash(homeDir) {
29
+ return crypto.createHash('sha256').update(path.resolve(homeDir)).digest('hex').slice(0, 8);
30
+ }
31
+
32
+ function sidecarName(homeDir) {
33
+ return `manyoyo-egress-${homeHash(homeDir)}`;
34
+ }
35
+
36
+ // 任何 HOME 的过滤代理 sidecar 名字:容器列表(网页 / ls)里要排除,它不是用户的会话容器
37
+ function isSidecarName(name) {
38
+ return /^manyoyo-egress-[0-9a-f]{8}$/.test(String(name || ''));
39
+ }
40
+
41
+ function ensureDir(dir) {
42
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ fs.chmodSync(dir, 0o700); // 只限制宿主机其他用户;容器里的 root(rootless 下就是宿主机用户,rootful 下是真 root)不受影响
44
+ }
45
+
46
+ function writeAtomic(file, text, mode) {
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, text, { mode });
49
+ fs.chmodSync(tmp, mode);
50
+ fs.renameSync(tmp, file);
51
+ }
52
+
53
+ function ipv4ToInt(ip) {
54
+ return ip.split('.').reduce((acc, part) => ((acc << 8) | Number(part)) >>> 0, 0);
55
+ }
56
+
57
+ function intToIpv4(value) {
58
+ return [24, 16, 8, 0].map(shift => (value >>> shift) & 255).join('.');
59
+ }
60
+
61
+ /**
62
+ * 在 manyoyo 网络网段里选 sidecar 的固定地址:网段内偏移 250(/24 即 .250),不同 HOME 的实例在
63
+ * 250 往下的几个地址里按 HOME 哈希错开;真的撞上了由 `run --ip` 报“地址已占用”,不会随机换。
64
+ */
65
+ function chooseSidecarIp(subnet, homeDir) {
66
+ const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(String(subnet || ''));
67
+ if (!m || !net.isIPv4(m[1])) throw new Error(`无法确定 manyoyo 网络的网段: ${subnet}`);
68
+ const prefix = Number(m[2]);
69
+ if (prefix < 8 || prefix > 29) throw new Error(`manyoyo 网络网段不适合放过滤代理: ${subnet}`);
70
+ const size = 2 ** (32 - prefix);
71
+ const base = (ipv4ToInt(m[1]) & ((0xffffffff << (32 - prefix)) >>> 0)) >>> 0;
72
+ const slot = parseInt(homeHash(homeDir), 16) % 5;
73
+ const offset = size > 256 ? 250 - slot : Math.max(2, size - 6 - slot);
74
+ return intToIpv4((base + offset) >>> 0);
75
+ }
76
+
77
+ // docker:IPAM.Config[].Subnet;podman 4/5:subnets[].subnet;返回第一个 IPv4 网段
78
+ function subnetOfNetwork(inspectJson) {
79
+ const parsed = Array.isArray(inspectJson) ? inspectJson[0] : inspectJson;
80
+ const candidates = [];
81
+ ((parsed && parsed.IPAM && parsed.IPAM.Config) || []).forEach(c => candidates.push(c.Subnet));
82
+ ((parsed && (parsed.subnets || parsed.Subnets)) || []).forEach(c => candidates.push(c.subnet || c.Subnet));
83
+ return candidates.find(s => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(String(s || ''))) || '';
84
+ }
85
+
86
+ // 上游代理在宿主机上的地址对 sidecar 容器可能不通:环回地址改写成容器看到的宿主机别名
87
+ function containerSideUpstream(upstream, hostAlias) {
88
+ if (!upstream) return '';
89
+ try {
90
+ const url = new URL(upstream);
91
+ if (url.protocol !== 'http:') return '';
92
+ if (/^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(url.hostname)) url.hostname = hostAlias;
93
+ return url.toString().replace(/\/$/, '');
94
+ } catch (e) {
95
+ return '';
96
+ }
97
+ }
98
+
99
+ // 固定 MAC(同 docker 的 02:42:<IP 四字节> 规则):重启后 MAC 变了,别的容器里缓存的 ARP 要等一分钟才失效,期间出网不通
100
+ function macForIp(ip) {
101
+ return `02:42:${ip.split('.').map(part => Number(part).toString(16).padStart(2, '0')).join(':')}`;
102
+ }
103
+
104
+ function appFileContents() {
105
+ return APP_FILES.map(name => [name, fs.readFileSync(path.join(__dirname, name), 'utf-8')]);
106
+ }
107
+
108
+ /**
109
+ * @param {object} options
110
+ * @param {(args: string[], opts?: object) => Promise<string>} options.run 运行时命令(容器运行时 CLI)
111
+ * @param {string} options.command 运行时命令名(docker / podman,用来判断宿主机别名)
112
+ * @param {string} options.homeDir
113
+ * @param {() => string} options.imageRef 当前镜像
114
+ * @param {string} options.networkName manyoyo 网络名
115
+ * @param {() => Promise<void>} options.ensureNetwork
116
+ * @param {() => string} [options.getUpstream] 上游代理(宿主机视角)
117
+ */
118
+ function createSidecarManager(options) {
119
+ const { run, homeDir, networkName } = options;
120
+ const getUpstream = options.getUpstream || (() => '');
121
+ const isDocker = /docker/i.test(path.basename(String(options.command || '')));
122
+ const hostAlias = isDocker ? 'host.docker.internal' : 'host.containers.internal';
123
+ const name = sidecarName(homeDir);
124
+ const hash = homeHash(homeDir);
125
+ let inflight = null;
126
+ let lastClients = '';
127
+
128
+ async function inspectSidecar() {
129
+ try {
130
+ const out = await run(['inspect', '--format', '{{json .}}', name]);
131
+ const line = out.split('\n').map(l => l.trim()).filter(Boolean)[0];
132
+ return line ? JSON.parse(line) : null;
133
+ } catch (e) {
134
+ if (/no such|not found|no container|does not exist/i.test(String(e.message))) return null;
135
+ throw e;
136
+ }
137
+ }
138
+
139
+ function ipOf(info) {
140
+ const networks = (info.NetworkSettings && info.NetworkSettings.Networks) || {};
141
+ return (networks[networkName] && networks[networkName].IPAddress) || '';
142
+ }
143
+
144
+ function runningOf(info) {
145
+ return Boolean(info.State && (info.State.Running === true || info.State.Status === 'running'));
146
+ }
147
+
148
+ function prepareFiles(upstream) {
149
+ const p = egressPaths(homeDir);
150
+ ensureDir(path.join(homeDir, '.manyoyo'));
151
+ ensureDir(p.dir);
152
+ [p.app, p.data, p.denied].forEach(ensureDir);
153
+ const contents = appFileContents();
154
+ contents.forEach(([file, text]) => {
155
+ const target = path.join(p.app, file);
156
+ if (!fs.existsSync(target) || fs.readFileSync(target, 'utf-8') !== text) writeAtomic(target, text, 0o644);
157
+ });
158
+ const upstreamFile = path.join(p.data, 'upstream.txt');
159
+ if (upstream) writeConfigFileSecure(upstreamFile, `${upstream}\n`);
160
+ else fs.rmSync(upstreamFile, { force: true });
161
+ const rev = crypto.createHash('sha256');
162
+ contents.forEach(([file, text]) => rev.update(`${file}\0${text}\0`));
163
+ return { p, rev: rev.digest('hex').slice(0, 16) };
164
+ }
165
+
166
+ // 就绪 = 在 sidecar 里真的连得上代理端口(不能看日志:重启后日志里还留着上一次的“ready”)
167
+ async function waitReady() {
168
+ const deadline = Date.now() + READY_TIMEOUT_MS;
169
+ const probe = `require('net').connect(${SIDECAR_PORT},'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))`;
170
+ while (Date.now() < deadline) {
171
+ const info = await inspectSidecar();
172
+ if (!info || !runningOf(info)) break;
173
+ try {
174
+ await run(['exec', name, 'node', '-e', probe], { timeout: 5000 });
175
+ return;
176
+ } catch (e) { /* 还没起来 */ }
177
+ await new Promise(resolve => setTimeout(resolve, 150));
178
+ }
179
+ let tail = '';
180
+ try { tail = await run(['logs', '--tail', '5', name]); } catch (e) { /* 取不到就算了 */ }
181
+ throw new Error(`域名白名单代理没有起来${tail ? `: ${String(tail).trim().split('\n').slice(-2).join(' ')}` : ''}`);
182
+ }
183
+
184
+ async function doEnsure() {
185
+ await options.ensureNetwork();
186
+ const subnet = subnetOfNetwork(JSON.parse((await run(['network', 'inspect', networkName, '--format', '{{json .}}'])).trim().split('\n')[0] || 'null'));
187
+ const ip = chooseSidecarIp(subnet, homeDir);
188
+ const upstream = containerSideUpstream(getUpstream(), hostAlias);
189
+ const { p, rev } = prepareFiles(upstream);
190
+ let info = await inspectSidecar();
191
+ const labels = (info && info.Config && info.Config.Labels) || {};
192
+ const matches = info && labels['manyoyo.egress.rev'] === rev && labels['manyoyo.home'] === hash;
193
+ if (matches && runningOf(info) && ipOf(info) === ip) return { name, ip, port: SIDECAR_PORT };
194
+ if (matches && !runningOf(info)) {
195
+ try {
196
+ await run(['start', name]);
197
+ info = await inspectSidecar();
198
+ if (info && runningOf(info) && ipOf(info) === ip) {
199
+ await waitReady();
200
+ return { name, ip, port: SIDECAR_PORT };
201
+ }
202
+ } catch (e) { /* 起不来就重建 */ }
203
+ }
204
+ if (info) await run(['rm', '-f', name]);
205
+ const args = [
206
+ 'run', '-d', '--name', name,
207
+ '--network', networkName, '--ip', ip, '--mac-address', macForIp(ip),
208
+ // 宿主机上这几个目录属于普通用户且是 0700:rootful 运行时里的 root 不带 DAC_OVERRIDE 就进不去(rootless 下 root 就是该用户,不受影响)
209
+ '--user', 'root', '--cap-drop', 'ALL', '--cap-add', 'DAC_OVERRIDE', '--security-opt', 'no-new-privileges',
210
+ '--read-only', '--tmpfs', '/tmp', '--pids-limit', '256', '--memory', '256m',
211
+ '--pull=never',
212
+ '--label', 'manyoyo.role=egress', '--label', `manyoyo.home=${hash}`, '--label', `manyoyo.egress.rev=${rev}`,
213
+ '--volume', `${p.app}:/app:ro`, '--volume', `${p.data}:/data:ro`, '--volume', `${p.denied}:/denied`,
214
+ '--env', `EGRESS_PORT=${SIDECAR_PORT}`
215
+ ];
216
+ if (isDocker) args.push('--add-host', 'host.docker.internal:host-gateway');
217
+ args.push('--entrypoint', 'node', options.imageRef(), '/app/egress-sidecar-main.js');
218
+ try {
219
+ await run(args, { timeout: 60000 });
220
+ } catch (e) {
221
+ const message = String(e.message);
222
+ // 另一个进程(CLI / serve)刚好同时建好了它:直接用现成的
223
+ if (/name .*(already in use|is already used)|already in use by container|name is already/i.test(message)) {
224
+ const other = await inspectSidecar();
225
+ if (other && runningOf(other) && ipOf(other) === ip) {
226
+ await waitReady();
227
+ return { name, ip, port: SIDECAR_PORT };
228
+ }
229
+ }
230
+ throw new Error(/address already in use|ip.*(used|allocated)|in use/i.test(message) && !/name/i.test(message)
231
+ ? `域名白名单代理需要的地址 ${ip} 已被占用(${networkName} 网络里有别的容器用了它)`
232
+ : `域名白名单代理启动失败: ${message}`);
233
+ }
234
+ await waitReady();
235
+ return { name, ip, port: SIDECAR_PORT };
236
+ }
237
+
238
+ /** 保证 sidecar 在运行,返回 {name, ip, port};多处同时调用合并成一次。 */
239
+ function ensure() {
240
+ if (!inflight) inflight = doEnsure().finally(() => { inflight = null; });
241
+ return inflight;
242
+ }
243
+
244
+ /** 只读检查:sidecar 现在是否在运行且地址正确(ensureReady 的快速路径用)。 */
245
+ async function isHealthy() {
246
+ const info = await inspectSidecar();
247
+ return Boolean(info && runningOf(info));
248
+ }
249
+
250
+ /** 写入「来源 IP → 容器 id + 策略」;内容没变就不写(避免无谓刷新)。 */
251
+ function writeClients(clients) {
252
+ const p = egressPaths(homeDir);
253
+ ensureDir(p.dir);
254
+ ensureDir(p.data);
255
+ const text = `${JSON.stringify({ clients }, null, 2)}\n`;
256
+ if (text === lastClients && fs.existsSync(path.join(p.data, 'clients.json'))) return;
257
+ writeAtomic(path.join(p.data, 'clients.json'), text, 0o644);
258
+ lastClients = text;
259
+ }
260
+
261
+ return { name, ensure, isHealthy, writeClients, paths: () => egressPaths(homeDir), isSidecarName: n => n === name };
262
+ }
263
+
264
+ module.exports = {
265
+ SIDECAR_PORT,
266
+ APP_FILES,
267
+ egressDir,
268
+ egressPaths,
269
+ sidecarName,
270
+ isSidecarName,
271
+ chooseSidecarIp,
272
+ macForIp,
273
+ subnetOfNetwork,
274
+ containerSideUpstream,
275
+ createSidecarManager
276
+ };
@@ -232,7 +232,7 @@ function applyTextReplacements(text, replacements) {
232
232
  }
233
233
 
234
234
  function formatPropertyKey(key) {
235
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
235
+ return JSON.stringify(key);
236
236
  }
237
237
 
238
238
  // 某个位置所在行的缩进
@@ -246,8 +246,7 @@ function reindent(valueText, indent) {
246
246
  return String(valueText).replace(/\n/g, `\n${indent}`);
247
247
  }
248
248
 
249
- // 永远只在对象的 '{' 后面插入,不管这个对象原来是空的还是已经有属性——
250
- // 不用去判断"是不是最后一个属性、要不要补逗号",语法上总是安全的
249
+ // 永远只在对象的 '{' 后面插入;对象原来已有属性才补逗号,空对象里插入第一个属性不留尾逗号
251
250
  function insertObjectProperty(text, objectStartIndex, key, valueText) {
252
251
  const objectIndent = lineIndentAt(text, objectStartIndex);
253
252
  const childIndent = `${objectIndent} `;
@@ -256,8 +255,9 @@ function insertObjectProperty(text, objectStartIndex, key, valueText) {
256
255
  const lineEnd = text.indexOf('\n', objectStartIndex + 1);
257
256
  const tailIsTrivia = lineEnd !== -1 && /^\s*(\/\/.*)?$/.test(text.slice(objectStartIndex + 1, lineEnd));
258
257
  // 空对象且 } 紧跟在同一行:闭合的 } 另起一行;} 已在后面的行上就不再补,避免多出空行
259
- const closesRightAfter = !tailIsTrivia && text[skipTrivia(text, objectStartIndex + 1)] === '}';
260
- const insertion = `${prefix}${reindent(valueText, childIndent)},${closesRightAfter ? `\n${objectIndent}` : ''}`;
258
+ const nextChar = text[skipTrivia(text, objectStartIndex + 1)];
259
+ const closesRightAfter = !tailIsTrivia && nextChar === '}';
260
+ const insertion = `${prefix}${reindent(valueText, childIndent)}${nextChar === '}' ? '' : ','}${closesRightAfter ? `\n${objectIndent}` : ''}`;
261
261
  const insertPos = tailIsTrivia ? lineEnd : objectStartIndex + 1;
262
262
  const nextText = `${text.slice(0, insertPos)}${insertion}${text.slice(insertPos)}`;
263
263
  return { text: nextText, valueStart: insertPos + prefix.length };
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ // 访问 GitHub 的请求读取代理:环境变量(https_proxy 等)或 macOS 系统代理。
4
+ // Node 内置 fetch 不读代理变量,所以这里按请求构造带代理的 fetch,绝不修改全局(serve 进程里还有访问本机/容器的请求)。
5
+
6
+ const http = require('http');
7
+ const https = require('https');
8
+ const { Readable } = require('stream');
9
+ const { spawnSync } = require('child_process');
10
+
11
+ const MAX_REDIRECTS = 5;
12
+ const IDLE_TIMEOUT_MS = 60000;
13
+
14
+ function pick(env, names) {
15
+ for (const name of names) {
16
+ const value = String(env[name] || env[name.toUpperCase()] || '').trim();
17
+ if (value) return value;
18
+ }
19
+ return '';
20
+ }
21
+
22
+ // 去掉账号密码,只留 scheme://host:port,用于打印
23
+ function displayProxy(url) {
24
+ try {
25
+ const parsed = new URL(url);
26
+ return `${parsed.protocol}//${parsed.host}`;
27
+ } catch (error) {
28
+ return '';
29
+ }
30
+ }
31
+
32
+ function normalizeProxyUrl(value) {
33
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`;
34
+ }
35
+
36
+ /**
37
+ * 解析 macOS `scutil --proxy` 的输出。返回 { url, noProxy } 或 { unsupported } 或 null。
38
+ * 只认 HTTPS 代理(没有就退到 HTTP 代理);PAC / 自动代理不解析。
39
+ */
40
+ function parseScutilProxy(text) {
41
+ const values = {};
42
+ const exceptions = [];
43
+ let inExceptions = false;
44
+ for (const rawLine of String(text || '').split('\n')) {
45
+ const line = rawLine.trim();
46
+ if (/^ExceptionsList\s*:/.test(line)) { inExceptions = true; continue; }
47
+ if (inExceptions) {
48
+ const item = line.match(/^\d+\s*:\s*(.+)$/);
49
+ if (item) { exceptions.push(item[1].trim()); continue; }
50
+ inExceptions = false;
51
+ }
52
+ const pair = line.match(/^(\w+)\s*:\s*(.+)$/);
53
+ if (pair) values[pair[1]] = pair[2].trim();
54
+ }
55
+ const noProxy = exceptions.map(item => item.replace(/^\*\./, '.')).join(',');
56
+ for (const prefix of ['HTTPS', 'HTTP']) {
57
+ if (values[`${prefix}Enable`] === '1' && values[`${prefix}Proxy`] && /^\d+$/.test(values[`${prefix}Port`] || '')) {
58
+ return { url: `http://${values[`${prefix}Proxy`]}:${values[`${prefix}Port`]}`, noProxy };
59
+ }
60
+ }
61
+ if (values.ProxyAutoConfigEnable === '1' || values.SOCKSEnable === '1') return { unsupported: true };
62
+ return null;
63
+ }
64
+
65
+ function readScutilProxy() {
66
+ const result = spawnSync('/usr/sbin/scutil', ['--proxy'], { encoding: 'utf-8', timeout: 3000 });
67
+ return result.status === 0 ? result.stdout : '';
68
+ }
69
+
70
+ /**
71
+ * 找出当前该用的代理。优先级:环境变量 > macOS 系统代理 > 无。
72
+ * 返回 { proxyEnv, source: 'env'|'system'|null, display, notice };notice 是需要告诉用户的一句话(如暂不支持 socks)。
73
+ */
74
+ function resolveProxy({ env = process.env, platform = process.platform, readSystemProxy = readScutilProxy } = {}) {
75
+ const noProxy = pick(env, ['no_proxy']);
76
+ const candidates = [pick(env, ['https_proxy']), pick(env, ['http_proxy']), pick(env, ['all_proxy'])].filter(Boolean);
77
+ let notice = '';
78
+ for (const candidate of candidates) {
79
+ const url = normalizeProxyUrl(candidate);
80
+ if (/^socks/i.test(url)) {
81
+ notice = '暂不支持 socks 代理,请改用 http 代理端口。';
82
+ continue;
83
+ }
84
+ if (!/^https?:\/\//i.test(url) || !displayProxy(url)) continue;
85
+ return { proxyEnv: { HTTPS_PROXY: url, HTTP_PROXY: url, ...(noProxy ? { NO_PROXY: noProxy } : {}) }, source: 'env', display: displayProxy(url), notice };
86
+ }
87
+ if (platform === 'darwin') {
88
+ let parsed = null;
89
+ try {
90
+ parsed = parseScutilProxy(readSystemProxy());
91
+ } catch (error) {
92
+ parsed = null;
93
+ }
94
+ if (parsed && parsed.url) {
95
+ const merged = [noProxy, parsed.noProxy].filter(Boolean).join(',');
96
+ return { proxyEnv: { HTTPS_PROXY: parsed.url, HTTP_PROXY: parsed.url, ...(merged ? { NO_PROXY: merged } : {}) }, source: 'system', display: displayProxy(parsed.url), notice };
97
+ }
98
+ if (parsed && parsed.unsupported) notice = '系统代理用了自动配置或 socks,暂不支持,请在终端设置 https_proxy 指向 http 代理端口。';
99
+ }
100
+ return { proxyEnv: null, source: null, display: '', notice };
101
+ }
102
+
103
+ // Agent 的 proxyEnv 选项:Node 22.21+ / 24.5+ 才有
104
+ function supportsProxyEnv(version = process.versions.node) {
105
+ const [major, minor] = String(version).split('.').map(Number);
106
+ if (major >= 25) return true;
107
+ if (major === 24) return minor >= 5;
108
+ if (major === 22) return minor >= 21;
109
+ return false;
110
+ }
111
+
112
+ function requestOnce(url, options, agents) {
113
+ const target = new URL(url);
114
+ const client = target.protocol === 'http:' ? http : https;
115
+ const agent = target.protocol === 'http:' ? agents.http : agents.https;
116
+ return new Promise((resolve, reject) => {
117
+ const req = client.request(target, { method: options.method || 'GET', headers: options.headers || {}, agent, signal: options.signal }, resolve);
118
+ // 连接或下载中途卡住(代理停止转发等)时不能永远挂着:空闲超过 60 秒按超时处理
119
+ req.setTimeout(IDLE_TIMEOUT_MS, () => req.destroy(Object.assign(new Error('空闲超时'), { code: 'ETIMEDOUT' })));
120
+ req.on('error', reject);
121
+ req.end();
122
+ });
123
+ }
124
+
125
+ /**
126
+ * 带代理的最小 fetch 兼容实现(status / ok / headers.get / json / text / body)。
127
+ * 手动跟随最多 5 次重定向,每一跳都由 Agent 按 NO_PROXY 重新判断是否走代理;HTTPS 目标走 CONNECT 隧道,证书照常校验。
128
+ */
129
+ function createProxyFetch(proxyEnv, agentOptions = {}) {
130
+ const agents = {
131
+ http: new http.Agent({ proxyEnv, keepAlive: false }),
132
+ https: new https.Agent({ proxyEnv, keepAlive: false, ...agentOptions })
133
+ };
134
+ return async function proxyFetch(url, options = {}) {
135
+ let current = String(url);
136
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
137
+ const res = await requestOnce(current, options, agents);
138
+ const location = res.headers.location;
139
+ if (res.statusCode >= 300 && res.statusCode < 400 && location && options.redirect !== 'manual') {
140
+ res.resume();
141
+ const next = new URL(location, current);
142
+ // 跨站跳转不带凭据类请求头
143
+ if (next.host !== new URL(current).host && options.headers) {
144
+ options = { ...options, headers: Object.fromEntries(Object.entries(options.headers).filter(([key]) => !/^(authorization|cookie)$/i.test(key))) };
145
+ }
146
+ current = next.toString();
147
+ continue;
148
+ }
149
+ const headers = { get: name => { const value = res.headers[String(name).toLowerCase()]; return Array.isArray(value) ? value.join(', ') : value === undefined ? null : value; } };
150
+ const readText = async () => {
151
+ const chunks = [];
152
+ for await (const chunk of res) chunks.push(chunk);
153
+ return Buffer.concat(chunks).toString('utf-8');
154
+ };
155
+ return {
156
+ status: res.statusCode,
157
+ ok: res.statusCode >= 200 && res.statusCode < 300,
158
+ headers,
159
+ url: current,
160
+ body: Readable.toWeb(res),
161
+ text: readText,
162
+ json: async () => JSON.parse(await readText())
163
+ };
164
+ }
165
+ throw Object.assign(new Error('重定向次数过多'), { cause: { code: 'TOO_MANY_REDIRECTS' } });
166
+ };
167
+ }
168
+
169
+ /**
170
+ * 访问 GitHub 用的 fetch:有可用代理时返回带代理的版本,否则返回内置 fetch。
171
+ * 返回 { fetch, proxy, notice };notice 是要打印给用户的提示(使用了哪个代理 / 为什么没用)。
172
+ */
173
+ function getGithubFetch({ env = process.env, platform = process.platform, readSystemProxy, nodeVersion = process.versions.node, baseFetch = globalThis.fetch } = {}) {
174
+ const proxy = resolveProxy({ env, platform, readSystemProxy });
175
+ if (!proxy.proxyEnv) return { fetch: baseFetch, proxy, notice: proxy.notice };
176
+ if (!supportsProxyEnv(nodeVersion)) {
177
+ return { fetch: baseFetch, proxy, notice: '当前 Node 版本不支持代理,请升级到 Node 22.21+ 或 24+。' };
178
+ }
179
+ const label = proxy.source === 'system' ? '使用系统代理' : '使用代理';
180
+ return { fetch: createProxyFetch(proxy.proxyEnv), proxy, notice: `${label}: ${proxy.display}` };
181
+ }
182
+
183
+ module.exports = { resolveProxy, parseScutilProxy, displayProxy, supportsProxyEnv, createProxyFetch, getGithubFetch };
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const net = require('net');
4
- const { isValidId } = require('./container-state');
4
+ const { isValidId } = require('./container-id');
5
5
 
6
6
  // 容器网络策略:校验(唯一入口)+ 生成 nft 规则文本。
7
7
  // nft 文本只由校验过的结构化数据生成:IP/CIDR 经 net.isIP + 前缀整数、端口是 1-65535 的整数、域名不进规则
@@ -196,6 +196,7 @@ function cidrList(cidrs, family) {
196
196
  * @param {string[]} [endpoints.dns]
197
197
  * @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo 必需端点、上游代理、过滤代理、Playwright、镜像、host 端口、出站 peer
198
198
  * @param {{ip:string, ports:string, proto?:string}[]} [endpoints.peerIn] 允许进来的 peer
199
+ * @param {{daddr:string, saddr:string}[]} [endpoints.antiSpoof] 发往 daddr 的包来源必须是 saddr(过滤代理按来源 IP 认容器)
199
200
  * @param {{subnet:string, gateway:string}|null} [endpoints.bridge]
200
201
  * @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);open 预设只删表
201
202
  */
@@ -204,6 +205,9 @@ function buildNftRuleset(policy, endpoints = {}) {
204
205
  if (policy.preset === 'open') return `${lines.join('\n')}\n`;
205
206
 
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
+ });
207
211
  // 用户 deny 排在所有放行规则之前,才能真正“禁止”
208
212
  if (policy.preset !== 'open') policy.deny.forEach(rule => out.push(` ${addrExpr('daddr', rule.cidr, 'deny')} reject`));
209
213
  (endpoints.dns || []).forEach(ip => {
@@ -612,7 +612,7 @@ class PlaywrightPlugin {
612
612
  async startChrome() {
613
613
  const { port } = this.config;
614
614
  const candidates = this.devtoolsCandidates();
615
- const enableHint = '请在 Chrome 地址栏进入 chrome://inspect/#remote-debugging,勾选“Allow remote debugging”(Chrome 没开就先打开它),然后重试。\n'
615
+ const enableHint = '请在 Chrome 地址栏进入:\nchrome://inspect/#remote-debugging\n勾选“Allow remote debugging”(Chrome 没开就先打开它),然后重试。\n'
616
616
  + '(自定义用户目录时,用 plugins.playwright.devtoolsActivePortPath 指定 DevToolsActivePort 文件。)';
617
617
  const target = findDevToolsActivePort(candidates);
618
618
  if (!target) {
@@ -821,7 +821,9 @@ class PlaywrightPlugin {
821
821
  if (!result.ok) {
822
822
  if (mode === 'chrome') {
823
823
  this.err('[up] chrome 失败: 连不上 Chrome 的调试接口。');
824
- this.err('请确认已在 chrome://inspect/#remote-debugging 开启远程调试,并在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(60 秒内),然后重试。');
824
+ this.err('请确认已在 Chrome 地址栏进入:');
825
+ this.err('chrome://inspect/#remote-debugging');
826
+ this.err('开启远程调试,并在 Chrome 弹出的“要允许远程调试吗?”里点“允许”(60 秒内),然后重试。');
825
827
  } else {
826
828
  this.err(`[up] ${mode} 失败: 浏览器服务已启动,但打不开页面(${result.error})。`);
827
829
  this.err(tailText(this.logPath(), 10));