@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.
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const dns = require('dns').promises;
5
+ const http = require('http');
6
+ const net = require('net');
7
+ const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
8
+ const { isValidId } = require('./container-state');
9
+
10
+ // 域名白名单的强制执行点:serve 进程内的 HTTP(S) 过滤代理。
11
+ // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按域名放行 CONNECT / 普通 HTTP,
12
+ // 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
13
+ // 除非该容器的 egress.rules 显式放行该 IP:端口。监听 0.0.0.0(容器只能经宿主机 LAN IP 访问宿主机),靠凭据挡局域网。
14
+ const REALM = 'manyoyo';
15
+ const CONNECT_TIMEOUT_MS = 15000;
16
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
17
+ const MAX_TUNNELS_PER_CONTAINER = 256;
18
+
19
+ const blocked = new net.BlockList();
20
+ [...PRIVATE_V4, '127.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32'].forEach(cidr => {
21
+ const [addr, prefix] = cidr.split('/');
22
+ blocked.addSubnet(addr, Number(prefix), 'ipv4');
23
+ });
24
+ [...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128'].forEach(cidr => {
25
+ const [addr, prefix] = cidr.split('/');
26
+ blocked.addSubnet(addr, Number(prefix), 'ipv6');
27
+ });
28
+
29
+ // ::ffff:a.b.c.d 与 NAT64(64:ff9b::/96)里嵌的 IPv4 要按 IPv4 判断
30
+ function embeddedV4(ip) {
31
+ const lower = ip.toLowerCase();
32
+ const mapped = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
33
+ if (mapped) return mapped[1];
34
+ const hex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
35
+ if (hex) {
36
+ const a = parseInt(hex[1], 16);
37
+ const b = parseInt(hex[2], 16);
38
+ return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
39
+ }
40
+ return '';
41
+ }
42
+
43
+ function isRestrictedAddress(ip) {
44
+ const v4 = embeddedV4(ip);
45
+ if (v4) return blocked.check(v4, 'ipv4');
46
+ const family = net.isIP(ip);
47
+ if (!family) return true;
48
+ return blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6');
49
+ }
50
+
51
+ function ruleCovers(rule, ip, port) {
52
+ if (rule.proto && rule.proto !== 'tcp') return false;
53
+ if (rule.ports) {
54
+ const ok = rule.ports.split(',').some(item => {
55
+ const [a, b] = item.split('-').map(Number);
56
+ return port >= a && port <= (b === undefined ? a : b);
57
+ });
58
+ if (!ok) return false;
59
+ }
60
+ const [addr, prefixText] = rule.cidr.split('/');
61
+ const family = net.isIP(addr);
62
+ if (!family || net.isIP(ip) !== family) return false;
63
+ const list = new net.BlockList();
64
+ list.addSubnet(addr, prefixText === undefined ? (family === 4 ? 32 : 128) : Number(prefixText), family === 4 ? 'ipv4' : 'ipv6');
65
+ return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
66
+ }
67
+
68
+ function parseBasicAuth(header) {
69
+ const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(String(header || '').trim());
70
+ if (!m) return null;
71
+ const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
72
+ const i = decoded.indexOf(':');
73
+ if (i < 0) return null;
74
+ return { id: decoded.slice(0, i), token: decoded.slice(i + 1) };
75
+ }
76
+
77
+ function tokenEquals(a, b) {
78
+ const x = Buffer.from(String(a));
79
+ const y = Buffer.from(String(b));
80
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
81
+ }
82
+
83
+ function parseTarget(text) {
84
+ const m = /^(?:\[([0-9a-fA-F:.]+)\]|([^:/\s]+)):(\d{1,5})$/.exec(text);
85
+ if (!m) return null;
86
+ const port = Number(m[3]);
87
+ if (port < 1 || port > 65535) return null;
88
+ return { host: (m[1] || m[2]).toLowerCase().replace(/\.$/, ''), port };
89
+ }
90
+
91
+ /**
92
+ * @param {object} options
93
+ * @param {(id: string) => string|null} options.getToken 容器的代理 token(没有返回 null)
94
+ * @param {(id: string) => {preset: string, domains: string[], rules: object[]}|null} options.getPolicy
95
+ * @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
96
+ * @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
97
+ */
98
+ function createEgressProxy(options) {
99
+ const getToken = options.getToken;
100
+ const getPolicy = options.getPolicy;
101
+ const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
102
+ let upstream = null;
103
+ if (options.upstream) {
104
+ try {
105
+ const url = new URL(options.upstream);
106
+ if (url.protocol === 'http:') {
107
+ upstream = { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80, auth: url.username ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' };
108
+ }
109
+ } catch (e) { /* 无效上游当作没有 */ }
110
+ }
111
+ const tunnels = new Map();
112
+ const sockets = new Set(); // CONNECT 劫持出来的 socket 不在 http.Server 的跟踪里,stop 时要自己关
113
+ let server = null;
114
+
115
+ function authenticate(req) {
116
+ const cred = parseBasicAuth(req.headers['proxy-authorization']);
117
+ if (!cred || !isValidId(cred.id)) return null;
118
+ const token = getToken(cred.id);
119
+ if (!token || !tokenEquals(token, cred.token)) return null;
120
+ return cred.id;
121
+ }
122
+
123
+ // 返回 {ok:true, ip?} 或 {ok:false, status, message}
124
+ async function authorize(id, host, port) {
125
+ const policy = getPolicy(id);
126
+ if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'policy' };
127
+ const isIp = net.isIP(host) !== 0;
128
+ const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
129
+ if (isIp) {
130
+ if (isRestrictedAddress(host) && !explicit(host, port)) return { ok: false, status: 403, message: 'address' };
131
+ if (!explicit(host, port)) return { ok: false, status: 403, message: 'domain' };
132
+ return { ok: true, ip: host };
133
+ }
134
+ if (!domainAllowed(policy.domains, host)) return { ok: false, status: 403, message: 'domain' };
135
+ if (upstream) {
136
+ // 经上游代理时真正的连接由上游解析;这里仍做一次本地解析,解析得到私有 / 环回地址(且没有被显式放行)就拒绝。
137
+ // 本地解析不出来(宿主机不一定能解析)则放行给上游。残余风险:白名单里的通配域名若被控制了 DNS,
138
+ // 本地与上游可能得到不同答案——不要在白名单里放你不信任的通配域名。
139
+ try {
140
+ const local = await lookup(host);
141
+ if (local.length && local.every(ip => isRestrictedAddress(ip) && !explicit(ip, port))) return { ok: false, status: 403, message: 'address' };
142
+ } catch (e) { /* 本地解析失败:交给上游 */ }
143
+ return { ok: true, ip: '' };
144
+ }
145
+ let addresses;
146
+ try {
147
+ addresses = await lookup(host);
148
+ } catch (e) {
149
+ return { ok: false, status: 502, message: 'dns' };
150
+ }
151
+ const usable = addresses.filter(ip => !isRestrictedAddress(ip) || explicit(ip, port));
152
+ if (!usable.length) return { ok: false, status: 403, message: 'address' };
153
+ return { ok: true, ip: usable[0] };
154
+ }
155
+
156
+ function reject(res, status, message) {
157
+ const headers = { 'Content-Type': 'text/plain; charset=utf-8', Connection: 'close' };
158
+ if (status === 407) headers['Proxy-Authenticate'] = `Basic realm="${REALM}"`;
159
+ res.writeHead(status, headers);
160
+ res.end(`manyoyo egress proxy: ${message}\n`);
161
+ }
162
+
163
+ function acquire(id) {
164
+ const n = tunnels.get(id) || 0;
165
+ if (n >= MAX_TUNNELS_PER_CONTAINER) return false;
166
+ tunnels.set(id, n + 1);
167
+ return true;
168
+ }
169
+
170
+ function release(id) {
171
+ const n = (tunnels.get(id) || 1) - 1;
172
+ if (n <= 0) tunnels.delete(id); else tunnels.set(id, n);
173
+ }
174
+
175
+ function rawReject(socket, status, text) {
176
+ const extra = status === 407 ? `Proxy-Authenticate: Basic realm="${REALM}"\r\n` : '';
177
+ socket.end(`HTTP/1.1 ${status} ${text}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
178
+ }
179
+
180
+ function track(socket) {
181
+ sockets.add(socket);
182
+ socket.once('close', () => sockets.delete(socket));
183
+ return socket;
184
+ }
185
+
186
+ async function onConnect(req, clientSocket, head) {
187
+ track(clientSocket);
188
+ clientSocket.on('error', () => {});
189
+ const id = authenticate(req);
190
+ if (!id) return rawReject(clientSocket, 407, 'Proxy Authentication Required');
191
+ const target = parseTarget(String(req.url || ''));
192
+ if (!target) return rawReject(clientSocket, 400, 'Bad Request');
193
+ const verdict = await authorize(id, target.host, target.port);
194
+ if (!verdict.ok) return rawReject(clientSocket, verdict.status, verdict.status === 403 ? 'Forbidden' : 'Bad Gateway');
195
+ if (!acquire(id)) return rawReject(clientSocket, 429, 'Too Many Requests');
196
+ let released = false;
197
+ const done = () => { if (!released) { released = true; release(id); } };
198
+ let upstreamSocket;
199
+ if (upstream) {
200
+ upstreamSocket = track(net.connect({ host: upstream.host, port: upstream.port }));
201
+ upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
202
+ upstreamSocket.once('connect', () => {
203
+ upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
204
+ const authLine = upstream.auth ? `Proxy-Authorization: ${upstream.auth}\r\n` : '';
205
+ const hostPort = net.isIPv6(target.host) ? `[${target.host}]:${target.port}` : `${target.host}:${target.port}`;
206
+ upstreamSocket.write(`CONNECT ${hostPort} HTTP/1.1\r\nHost: ${hostPort}\r\n${authLine}\r\n`);
207
+ let buf = Buffer.alloc(0);
208
+ const onData = chunk => {
209
+ buf = Buffer.concat([buf, chunk]);
210
+ const end = buf.indexOf('\r\n\r\n');
211
+ if (end < 0) { if (buf.length > 16384) upstreamSocket.destroy(); return; }
212
+ upstreamSocket.removeListener('data', onData);
213
+ const status = Number((/^HTTP\/1\.[01] (\d{3})/.exec(buf.toString('latin1', 0, end)) || [])[1]);
214
+ if (status !== 200) { rawReject(clientSocket, 502, 'Bad Gateway'); upstreamSocket.destroy(); return; }
215
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
216
+ const rest = buf.subarray(end + 4);
217
+ if (rest.length) clientSocket.write(rest);
218
+ if (head && head.length) upstreamSocket.write(head);
219
+ upstreamSocket.pipe(clientSocket);
220
+ clientSocket.pipe(upstreamSocket);
221
+ };
222
+ upstreamSocket.on('data', onData);
223
+ });
224
+ } else {
225
+ upstreamSocket = track(net.connect({ host: verdict.ip, port: target.port }));
226
+ upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
227
+ upstreamSocket.once('connect', () => {
228
+ upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
229
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
230
+ if (head && head.length) upstreamSocket.write(head);
231
+ upstreamSocket.pipe(clientSocket);
232
+ clientSocket.pipe(upstreamSocket);
233
+ });
234
+ }
235
+ upstreamSocket.on('error', () => { if (!clientSocket.destroyed) rawReject(clientSocket, 502, 'Bad Gateway'); });
236
+ upstreamSocket.on('close', () => { done(); clientSocket.destroy(); });
237
+ clientSocket.on('close', () => { done(); upstreamSocket.destroy(); });
238
+ }
239
+
240
+ // 普通 HTTP 代理(绝对 URI 请求)
241
+ async function onRequest(req, res) {
242
+ const id = authenticate(req);
243
+ if (!id) return reject(res, 407, 'proxy authentication required');
244
+ let url;
245
+ try { url = new URL(req.url); } catch (e) { return reject(res, 400, 'absolute URI required'); }
246
+ if (url.protocol !== 'http:') return reject(res, 400, 'only http: URIs');
247
+ const port = Number(url.port) || 80;
248
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
249
+ const verdict = await authorize(id, host, port);
250
+ if (!verdict.ok) return reject(res, verdict.status, verdict.status === 403 ? 'forbidden by policy' : 'bad gateway');
251
+ if (!acquire(id)) return reject(res, 429, 'too many connections');
252
+ const headers = { ...req.headers };
253
+ delete headers['proxy-authorization'];
254
+ delete headers['proxy-connection'];
255
+ const outbound = upstream
256
+ ? { host: upstream.host, port: upstream.port, path: req.url, headers: { ...headers, ...(upstream.auth ? { 'proxy-authorization': upstream.auth } : {}) } }
257
+ : { host: verdict.ip, port, path: `${url.pathname}${url.search}`, headers };
258
+ const proxyReq = http.request({ ...outbound, method: req.method, timeout: CONNECT_TIMEOUT_MS });
259
+ let released = false;
260
+ const done = () => { if (!released) { released = true; release(id); } };
261
+ proxyReq.on('response', proxyRes => {
262
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
263
+ proxyRes.pipe(res);
264
+ });
265
+ proxyReq.on('timeout', () => proxyReq.destroy());
266
+ proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, 'bad gateway'); else res.destroy(); });
267
+ res.on('close', () => { done(); proxyReq.destroy(); });
268
+ req.pipe(proxyReq);
269
+ }
270
+
271
+ function start(listen) {
272
+ return new Promise((resolve, fail) => {
273
+ server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, 'error'); } catch (e) { /* 连接已断 */ } }); });
274
+ server.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });
275
+ server.headersTimeout = 20000;
276
+ server.on('clientError', (err, socket) => socket.destroy());
277
+ server.once('error', fail);
278
+ server.listen(listen.port, listen.host, () => {
279
+ server.removeListener('error', fail);
280
+ resolve(server.address().port);
281
+ });
282
+ });
283
+ }
284
+
285
+ function stop() {
286
+ return new Promise(resolve => {
287
+ if (!server) return resolve();
288
+ server.close(() => resolve());
289
+ server.closeAllConnections && server.closeAllConnections();
290
+ sockets.forEach(socket => socket.destroy());
291
+ server = null;
292
+ });
293
+ }
294
+
295
+ return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
296
+ }
297
+
298
+ module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseBasicAuth, parseTarget, REALM };
@@ -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 };
@@ -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 };