@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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/bin/manyoyo.js +111 -48
  3. package/docker/manyoyo.Dockerfile +41 -8
  4. package/docker/res/agent/update-agents.sh +1 -1
  5. package/docker/res/playwright/browser.json +9 -12
  6. package/docker/res/playwright/chrome-policy.json +3 -0
  7. package/docker/res/playwright/env +3 -0
  8. package/docker/res/playwright/playwright-cli.sh +5 -0
  9. package/docker/res/playwright/stealth.init.js +11 -9
  10. package/lib/browser-background.js +23 -0
  11. package/lib/container-env.sh +34 -0
  12. package/lib/container-exec.js +132 -0
  13. package/lib/container-id.js +11 -0
  14. package/lib/container-init.sh +40 -0
  15. package/lib/container-manage-options.js +35 -0
  16. package/lib/container-network.js +615 -0
  17. package/lib/container-run.js +27 -3
  18. package/lib/container-state.js +467 -0
  19. package/lib/egress-denied.js +128 -0
  20. package/lib/egress-proxy.js +315 -0
  21. package/lib/egress-sidecar-main.js +112 -0
  22. package/lib/egress-sidecar.js +276 -0
  23. package/lib/env-text.js +62 -0
  24. package/lib/json5-text-edit.js +5 -5
  25. package/lib/network-endpoints.js +99 -0
  26. package/lib/network-policy.js +269 -0
  27. package/lib/plugin/fingerprint.js +65 -50
  28. package/lib/plugin/playwright-extensions.js +0 -9
  29. package/lib/plugin/playwright-server.js +22 -2
  30. package/lib/plugin/playwright.js +55 -16
  31. package/lib/port-forward.js +137 -0
  32. package/lib/web/AGENTS.md +4 -0
  33. package/lib/web/container-manage.js +506 -0
  34. package/lib/web/denied-notify.js +52 -0
  35. package/lib/web/index.html +81 -77
  36. package/lib/web/server.js +352 -135
  37. package/manyoyo.example.json +20 -2
  38. package/package.json +13 -8
@@ -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
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ // 环境变量的唯一语法(Web「环境变量」文本、容器内 /run/manyoyo/env、环境变量文件、容器内 init 都按它解析;
4
+ // 前端 frontend/src/lib/container-manage.ts 与 lib/container-init.sh 各有一份等价实现,用同一组语料测试):
5
+ // - 一行一项 `KEY=VALUE`,空行与 `#` 开头的行忽略;
6
+ // - 与 docker / podman 的 env-file 一致:不展开变量、不执行命令,值里的空格不需要引号(`KEY=abc 123`);
7
+ // - 兼容 shell / dotenv 常见写法:可选的 `export ` 前缀;`=` 两侧的空白;值两端成对的 "…" 或 '…' 会被去掉
8
+ // (docker 自己会把引号当成值的一部分,这里去掉更符合直觉)。想让值保留首尾的引号,就再包一层引号。
9
+ // - 写出时(serializeEnvEntries)只在值首尾有空白、或首尾恰好是一对引号时才加引号,其余原样,所以
10
+ // 表格里填的 `abc 123` 在文本里就是 `KEY=abc 123`。
11
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
+ const LINE_RE = /^(?:export\s+)?([^=\s]+)\s*=\s*(.*)$/;
13
+
14
+ function unquote(value) {
15
+ const v = value.trim();
16
+ if (v.length >= 2 && ((v[0] === '"' && v[v.length - 1] === '"') || (v[0] === "'" && v[v.length - 1] === "'"))) {
17
+ return v.slice(1, -1);
18
+ }
19
+ return v;
20
+ }
21
+
22
+ /**
23
+ * @returns {{entries: {key:string,value:string,line:number}[], invalid: {line:number,text:string,reason:string}[]}}
24
+ */
25
+ function parseEnvText(text) {
26
+ const entries = [];
27
+ const invalid = [];
28
+ String(text || '').split('\n').forEach((raw, index) => {
29
+ const line = raw.replace(/\r$/, '');
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith('#')) return;
32
+ const m = LINE_RE.exec(trimmed);
33
+ if (!m) {
34
+ invalid.push({ line: index + 1, text: line, reason: '缺少 KEY=VALUE' });
35
+ return;
36
+ }
37
+ if (!ENV_KEY_RE.test(m[1])) {
38
+ invalid.push({ line: index + 1, text: line, reason: `key 非法: ${m[1]}` });
39
+ return;
40
+ }
41
+ entries.push({ key: m[1], value: unquote(m[2]), line: index + 1 });
42
+ });
43
+ return { entries, invalid };
44
+ }
45
+
46
+ function needsQuote(value) {
47
+ return /^\s|\s$/.test(value)
48
+ || (value.length >= 2 && ((value[0] === '"' && value[value.length - 1] === '"') || (value[0] === "'" && value[value.length - 1] === "'")));
49
+ }
50
+
51
+ function formatEnvValue(value) {
52
+ if (!needsQuote(value)) return value;
53
+ const q = value[0] === '"' ? "'" : '"';
54
+ return `${q}${value}${q}`;
55
+ }
56
+
57
+ function serializeEnvEntries(entries) {
58
+ const lines = entries.filter(entry => String(entry.key).trim()).map(entry => `${String(entry.key).trim()}=${formatEnvValue(entry.value)}`);
59
+ return lines.length ? `${lines.join('\n')}\n` : '';
60
+ }
61
+
62
+ module.exports = { ENV_KEY_RE, parseEnvText, serializeEnvEntries, formatEnvValue };
@@ -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,99 @@
1
+ 'use strict';
2
+
3
+ const dns = require('dns').promises;
4
+ const net = require('net');
5
+ const { normalizePolicy, normalizeDomain } = require('./network-policy');
6
+ const { isRestrictedAddress } = require('./egress-proxy');
7
+
8
+ // 新建容器时从用户在创建时填写的 env 里推断“Agent 要访问的端点”(如 ANTHROPIC_BASE_URL、OPENAI_BASE_URL 指向宿主机上的
9
+ // Ollama 或局域网网关),把落在私有网段的写进网络策略,否则默认收紧会让 Agent 一上来就连不上自己配置的模型服务。
10
+ // 只在创建时、只读宿主机侧给定的 env(不读容器可写的 /run/manyoyo/env:容器不能靠改自己的 env 来放开自己的防火墙);
11
+ // 结果写进 network.json,在「容器」页的规则里看得到,也可以删。
12
+ // 代理变量的主机不算“Agent 要访问的服务”
13
+ const PROXY_KEYS = new Set(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY']);
14
+ const HOST_ALIASES = new Set(['host.containers.internal', 'host.docker.internal']);
15
+
16
+ function urlOf(value) {
17
+ if (!/^https?:\/\//i.test(value)) return null;
18
+ try {
19
+ const url = new URL(value);
20
+ const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
21
+ return { host: url.hostname.replace(/^\[|\]$/g, '').toLowerCase(), port };
22
+ } catch (e) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * env 里 URL 的域名(排除 IP、宿主机别名、代理变量):Agent 的模型服务(ANTHROPIC_BASE_URL、OPENAI_BASE_URL…)。
29
+ * 域名白名单模式下它们必须放行,否则 Agent 一发请求就被过滤代理拒绝(客户端只会看到 ERR_PROXY_TUNNEL)。
30
+ */
31
+ function envUrlDomains(envLines) {
32
+ const out = [];
33
+ for (const line of envLines || []) {
34
+ const text = String(line);
35
+ const i = text.indexOf('=');
36
+ if (i <= 0 || PROXY_KEYS.has(text.slice(0, i))) continue;
37
+ const target = urlOf(text.slice(i + 1));
38
+ if (!target || net.isIP(target.host) || HOST_ALIASES.has(target.host)) continue;
39
+ let domain;
40
+ try { domain = normalizeDomain(target.host); } catch (e) { continue; }
41
+ if (!out.includes(domain)) out.push(domain);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * @param {string[]} envLines KEY=VALUE(创建时的用户 env)
48
+ * @param {(host: string) => Promise<string[]>} [lookup]
49
+ * @returns {Promise<{host: {ports: string, proto: 'tcp'}[], rules: {cidr: string, ports: string, proto: 'tcp'}[]}>}
50
+ */
51
+ async function inferEnvEndpoints(envLines, lookup = async host => (await dns.lookup(host, { all: true })).map(r => r.address)) {
52
+ const host = [];
53
+ const rules = [];
54
+ const seen = new Set();
55
+ for (const line of envLines || []) {
56
+ const value = String(line).slice(String(line).indexOf('=') + 1);
57
+ const target = urlOf(value);
58
+ if (!target) continue;
59
+ const key = `${target.host}:${target.port}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ if (HOST_ALIASES.has(target.host)) {
63
+ host.push({ ports: String(target.port), proto: 'tcp' });
64
+ continue;
65
+ }
66
+ let addresses = [];
67
+ try {
68
+ addresses = net.isIP(target.host) ? [target.host] : await lookup(target.host);
69
+ } catch (e) {
70
+ addresses = [];
71
+ }
72
+ addresses.filter(ip => isRestrictedAddress(ip) && !/^127\./.test(ip) && ip !== '::1').forEach(ip => {
73
+ rules.push({ cidr: ip, ports: String(target.port), proto: 'tcp' });
74
+ });
75
+ }
76
+ return { host, rules, domains: envUrlDomains(envLines) };
77
+ }
78
+
79
+ /** 把推断出的端点并进(已校验的)策略;open 预设不需要。 */
80
+ async function withEnvEndpoints(policy, envLines, lookup) {
81
+ if (policy.preset === 'open') return policy;
82
+ const inferred = await inferEnvEndpoints(envLines, lookup);
83
+ if (!inferred.host.length && !inferred.rules.length && !inferred.domains.length) return policy;
84
+ const hostKey = r => `${r.ports}/${r.proto}`;
85
+ const ruleKey = r => `${r.cidr}|${r.ports}|${r.proto}`;
86
+ const haveHost = new Set(policy.host.map(hostKey));
87
+ const haveRules = new Set(policy.egress.rules.map(ruleKey));
88
+ return normalizePolicy({
89
+ ...policy,
90
+ host: [...policy.host, ...inferred.host.filter(r => !haveHost.has(hostKey(r)))],
91
+ egress: {
92
+ // 域名同样在任何预设下都登记:以后切到白名单时,模型服务的域名已经在列表里
93
+ domains: [...policy.egress.domains, ...inferred.domains.filter(d => !policy.egress.domains.includes(d))],
94
+ rules: [...policy.egress.rules, ...inferred.rules.filter(r => !haveRules.has(ruleKey(r)))]
95
+ }
96
+ });
97
+ }
98
+
99
+ module.exports = { inferEnvEndpoints, withEnvEndpoints, envUrlDomains };