@xcanwin/manyoyo 8.5.1 → 8.6.2

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.
@@ -3,27 +3,43 @@
3
3
  const dns = require('dns').promises;
4
4
  const http = require('http');
5
5
  const net = require('net');
6
- const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
6
+ const { domainMatches, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
7
7
 
8
- // 域名白名单的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
9
- // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按连接的来源 IP 认出是哪个容器(getClient),
10
- // 认不出的来源一律 403;按域名放行 CONNECT / 普通 HTTP,
11
- // 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
12
- // 除非该容器的 egress.rules 显式放行该 IP:端口。
8
+ // 域名规则的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
9
+ // 有启用域名规则的容器,HTTP(S) 都走这个代理,防火墙只放行到它;这里按连接的来源 IP 认出是哪个容器(getClient),
10
+ // 认不出的来源一律 403;然后按该容器编译好的有序规则(域名行 + 地址行 + 模式默认行)从上往下找第一条命中的。
11
+ // 地址行按“任一解析结果”匹配;通配域名命中后,解析结果若落在 @host / @private / @metadata,仍要有一条允许该地址的规则。
12
+ // 环回 / 未指定 / 组播地址永远拒绝(防借代理打 sidecar 自己)。
13
13
  // 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
14
14
  const CONNECT_TIMEOUT_MS = 15000;
15
15
  const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
16
16
  const MAX_TUNNELS_PER_CONTAINER = 256;
17
+ const LOOKUP_TIMEOUT_MS = 5000;
17
18
 
18
- const blocked = new net.BlockList();
19
- [...PRIVATE_V4, '127.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32'].forEach(cidr => {
20
- const [addr, prefix] = cidr.split('/');
21
- blocked.addSubnet(addr, Number(prefix), 'ipv4');
22
- });
23
- [...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128'].forEach(cidr => {
24
- const [addr, prefix] = cidr.split('/');
25
- blocked.addSubnet(addr, Number(prefix), 'ipv6');
26
- });
19
+ function withTimeout(promise, ms) {
20
+ let timer;
21
+ return Promise.race([promise, new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); })]).finally(() => clearTimeout(timer));
22
+ }
23
+
24
+ async function liveOnly(ips) {
25
+ return ips.filter(ip => !checkList(floor, ip));
26
+ }
27
+
28
+ function blockListOf(cidrs) {
29
+ const list = new net.BlockList();
30
+ (cidrs || []).forEach(cidr => {
31
+ const [addr, prefix] = String(cidr).split('/');
32
+ const family = net.isIP(addr);
33
+ if (!family) return;
34
+ const type = family === 4 ? 'ipv4' : 'ipv6';
35
+ if (prefix === undefined) list.addAddress(addr, type);
36
+ else list.addSubnet(addr, Number(prefix), type);
37
+ });
38
+ return list;
39
+ }
40
+
41
+ const blocked = blockListOf([...PRIVATE_V4, ...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128', '127.0.0.0/8', '255.255.255.255/32']);
42
+ const floor = blockListOf(['127.0.0.0/8', '0.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32', '::1/128', '::/128', 'ff00::/8']);
27
43
 
28
44
  // ::ffff:a.b.c.d 与 NAT64(64:ff9b::/96)里嵌的 IPv4 要按 IPv4 判断
29
45
  function embeddedV4(ip) {
@@ -39,29 +55,102 @@ function embeddedV4(ip) {
39
55
  return '';
40
56
  }
41
57
 
58
+ function checkList(list, ip) {
59
+ const addr = embeddedV4(ip) || ip;
60
+ const family = net.isIP(addr);
61
+ return family ? list.check(addr, family === 4 ? 'ipv4' : 'ipv6') : null;
62
+ }
63
+
42
64
  function isRestrictedAddress(ip) {
43
- const v4 = embeddedV4(ip);
44
- if (v4) return blocked.check(v4, 'ipv4');
45
- const family = net.isIP(ip);
46
- if (!family) return true;
47
- return blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6');
65
+ const hit = checkList(blocked, ip);
66
+ return hit === null ? true : hit;
48
67
  }
49
68
 
50
- function ruleCovers(rule, ip, port) {
51
- if (rule.proto && rule.proto !== 'tcp') return false;
52
- if (rule.ports) {
53
- const ok = rule.ports.split(',').some(item => {
54
- const [a, b] = item.split('-').map(Number);
55
- return port >= a && port <= (b === undefined ? a : b);
56
- });
57
- if (!ok) return false;
69
+ const setCache = new WeakMap();
70
+ function compiledSet(set) {
71
+ let compiled = setCache.get(set);
72
+ if (!compiled) {
73
+ compiled = { inc: blockListOf(set.inc), exc: blockListOf(set.exc) };
74
+ setCache.set(set, compiled);
58
75
  }
59
- const [addr, prefixText] = rule.cidr.split('/');
60
- const family = net.isIP(addr);
61
- if (!family || net.isIP(ip) !== family) return false;
62
- const list = new net.BlockList();
63
- list.addSubnet(addr, prefixText === undefined ? (family === 4 ? 32 : 128) : Number(prefixText), family === 4 ? 'ipv4' : 'ipv6');
64
- return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
76
+ return compiled;
77
+ }
78
+
79
+ /** 地址集合 {any, inc, exc} 是否包含 ip。 */
80
+ function setCovers(set, ip) {
81
+ if (!set) return false;
82
+ const compiled = compiledSet(set);
83
+ if (!set.any && !checkList(compiled.inc, ip)) return false;
84
+ return !checkList(compiled.exc, ip);
85
+ }
86
+
87
+ const listCache = new WeakMap();
88
+ function listCovers(cidrs, ip) {
89
+ let list = listCache.get(cidrs);
90
+ if (!list) {
91
+ list = blockListOf(cidrs);
92
+ listCache.set(cidrs, list);
93
+ }
94
+ return Boolean(checkList(list, ip));
95
+ }
96
+
97
+ function portsCover(ports, port) {
98
+ if (!ports) return true;
99
+ return String(ports).split(',').some(item => {
100
+ const [a, b] = item.split('-').map(Number);
101
+ return port >= a && port <= (b === undefined ? a : b);
102
+ });
103
+ }
104
+
105
+ /**
106
+ * 按容器编译好的有序规则判断一次连接。解析是惰性的:只有规则需要看地址时才查 DNS(被域名规则直接拒绝 / 放行的不用等 DNS)。
107
+ * @param {{rules: object[], default: string, sensitive?: string[]}} policy
108
+ * @param {string} host 目标主机(域名或 IP 字面量)
109
+ * @param {number} port
110
+ * @param {string[]|(() => Promise<string[]>)} ipsOrResolve 目标的解析结果,或惰性解析函数(目标本身是 IP 时就是它自己;解析不出来为空)
111
+ * @returns {Promise<{ok: boolean, reason?: string, usable?: string[]|null}>} usable 为 null 表示判断时没看地址,由调用方按解析结果挑
112
+ */
113
+ async function decide(policy, host, port, ipsOrResolve) {
114
+ const isIp = net.isIP(host) !== 0;
115
+ const deny = reason => ({ ok: false, reason });
116
+ let live = null;
117
+ const liveIps = async () => {
118
+ if (live === null) {
119
+ const ips = Array.isArray(ipsOrResolve) ? ipsOrResolve : (isIp ? [host] : await ipsOrResolve());
120
+ live = { all: ips, ok: ips.filter(ip => !checkList(floor, ip)) };
121
+ }
122
+ return live;
123
+ };
124
+ const applies = rule => rule.proto !== 'udp' && portsCover(rule.ports, port);
125
+ const addressAllowed = ip => {
126
+ for (const rule of policy.rules) {
127
+ if (rule.domain || !applies(rule) || !setCovers(rule.addr, ip)) continue;
128
+ return rule.action === 'allow';
129
+ }
130
+ return policy.default === 'allow';
131
+ };
132
+ for (const rule of policy.rules) {
133
+ if (!applies(rule)) continue;
134
+ if (rule.domain) {
135
+ if (isIp || !domainMatches(rule.domain, host)) continue;
136
+ if (rule.action === 'deny') return deny('domain');
137
+ if (rule.domain.startsWith('*.')) {
138
+ const { all, ok } = await liveIps();
139
+ if (all.length && !ok.length) return deny('address');
140
+ const sensitive = policy.sensitive || [];
141
+ const usable = ok.filter(ip => !listCovers(sensitive, ip) || addressAllowed(ip));
142
+ if (ok.length && !usable.length) return deny('address');
143
+ return { ok: true, usable };
144
+ }
145
+ return { ok: true, usable: null };
146
+ }
147
+ const { all, ok } = await liveIps();
148
+ if (all.length && !ok.length) return deny('address');
149
+ const hit = ok.filter(ip => setCovers(rule.addr, ip));
150
+ if (!hit.length) continue;
151
+ return rule.action === 'allow' ? { ok: true, usable: isIp ? ok : hit } : deny('address');
152
+ }
153
+ return policy.default === 'allow' ? { ok: true, usable: null } : deny('domain');
65
154
  }
66
155
 
67
156
  function parseTarget(text) {
@@ -74,14 +163,16 @@ function parseTarget(text) {
74
163
 
75
164
  /**
76
165
  * @param {object} options
77
- * @param {(ip: string) => {id: string, preset: string, domains: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
166
+ * @param {(ip: string) => {id: string, default: string, sensitive?: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
78
167
  * @param {(event: {id: string, host: string, port: number, reason: string}) => void} [options.onDenied] 拒绝记录
79
168
  * @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
80
169
  * @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
170
+ * @param {(ip: string) => string} [options.mapAddress] 测试替身:实际拨号的地址(单测里把虚构的公网 IP 映射到本机夹具)
81
171
  */
82
172
  function createEgressProxy(options) {
83
173
  const getClient = options.getClient;
84
174
  const onDenied = options.onDenied || (() => {});
175
+ const mapAddress = options.mapAddress || (ip => ip);
85
176
  const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
86
177
  // upstream 可以是字符串或返回字符串的函数(sidecar 里按文件改动热更新);解析结果按字符串缓存
87
178
  let parsedFor = null;
@@ -120,33 +211,24 @@ function createEgressProxy(options) {
120
211
 
121
212
  // 返回 {ok:true, ip?} 或 {ok:false, status, message}
122
213
  async function authorize(policy, host, port, upstream) {
123
- if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'domain' };
214
+ if (!policy || !Array.isArray(policy.rules)) return { ok: false, status: 403, message: 'domain' };
124
215
  const isIp = net.isIP(host) !== 0;
125
- const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
126
- if (isIp) {
127
- if (isRestrictedAddress(host) && !explicit(host, port)) return { ok: false, status: 403, message: 'address' };
128
- if (!explicit(host, port)) return { ok: false, status: 403, message: 'domain' };
129
- return { ok: true, ip: host };
130
- }
131
- if (!domainAllowed(policy.domains, host)) return { ok: false, status: 403, message: 'domain' };
132
- if (upstream) {
133
- // 经上游代理时真正的连接由上游解析;这里仍做一次本地解析,解析得到私有 / 环回地址(且没有被显式放行)就拒绝。
134
- // 本地解析不出来(宿主机不一定能解析)则放行给上游。残余风险:白名单里的通配域名若被控制了 DNS,
135
- // 本地与上游可能得到不同答案——不要在白名单里放你不信任的通配域名。
136
- try {
137
- const local = await lookup(host);
138
- if (local.length && local.every(ip => isRestrictedAddress(ip) && !explicit(ip, port))) return { ok: false, status: 403, message: 'address' };
139
- } catch (e) { /* 本地解析失败:交给上游 */ }
140
- return { ok: true, ip: '' };
141
- }
142
- let addresses;
143
- try {
144
- addresses = await lookup(host);
145
- } catch (e) {
146
- return { ok: false, status: 502, message: 'dns' };
147
- }
148
- const usable = addresses.filter(ip => !isRestrictedAddress(ip) || explicit(ip, port));
149
- if (!usable.length) return { ok: false, status: 403, message: 'address' };
216
+ let addresses = null;
217
+ const resolveIps = async () => {
218
+ if (addresses === null) {
219
+ addresses = [];
220
+ try {
221
+ addresses = await withTimeout(lookup(host), LOOKUP_TIMEOUT_MS);
222
+ } catch (e) { /* 解析不出来:规则里的域名行仍然判断;经上游代理时交给上游解析 */ }
223
+ }
224
+ return addresses;
225
+ };
226
+ const verdict = await decide(policy, host, port, isIp ? [host] : resolveIps);
227
+ if (!verdict.ok) return { ok: false, status: 403, message: verdict.reason };
228
+ if (upstream) return { ok: true, ip: '' };
229
+ // 直接拨号:需要地址。规则没看过地址时,从解析结果里挑非环回的
230
+ const usable = verdict.usable || (await liveOnly(isIp ? [host] : await resolveIps()));
231
+ if (!usable.length) return isIp || (addresses && addresses.length) ? { ok: false, status: 403, message: 'address' } : { ok: false, status: 502, message: 'dns' };
150
232
  return { ok: true, ip: usable[0] };
151
233
  }
152
234
 
@@ -234,7 +316,7 @@ function createEgressProxy(options) {
234
316
  upstreamSocket.on('data', onData);
235
317
  });
236
318
  } else {
237
- upstreamSocket = track(net.connect({ host: verdict.ip, port: target.port }));
319
+ upstreamSocket = track(net.connect({ host: mapAddress(verdict.ip), port: target.port }));
238
320
  upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
239
321
  upstreamSocket.once('connect', () => {
240
322
  upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
@@ -271,7 +353,7 @@ function createEgressProxy(options) {
271
353
  delete headers['proxy-connection'];
272
354
  const outbound = upstream
273
355
  ? { host: upstream.host, port: upstream.port, path: req.url, headers: { ...headers, ...(upstream.auth ? { 'proxy-authorization': upstream.auth } : {}) } }
274
- : { host: verdict.ip, port, path: `${url.pathname}${url.search}`, headers };
356
+ : { host: mapAddress(verdict.ip), port, path: `${url.pathname}${url.search}`, headers };
275
357
  const proxyReq = http.request({ ...outbound, method: req.method, timeout: CONNECT_TIMEOUT_MS });
276
358
  let released = false;
277
359
  const done = () => { if (!released) { released = true; release(id); } };
@@ -312,4 +394,4 @@ function createEgressProxy(options) {
312
394
  return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
313
395
  }
314
396
 
315
- module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseTarget };
397
+ module.exports = { createEgressProxy, isRestrictedAddress, decide, setCovers, parseTarget };
@@ -6,12 +6,29 @@ const { createEgressProxy } = require('./egress-proxy');
6
6
  const { createRecorder } = require('./egress-denied');
7
7
 
8
8
  // 过滤代理 sidecar 容器里的入口(宿主机把这几个文件只读挂进来,见 egress-sidecar.js)。
9
- // 数据目录(只读):clients.json(来源 IP → 容器 id + 策略,宿主机在每次下发规则时原子替换)、upstream.txt(可选,上游代理);
9
+ // 数据目录(只读):clients.json(来源 IP → 容器 id + 编译好的有序规则,宿主机在每次下发规则时原子替换)、upstream.txt(可选,上游代理);
10
10
  // 拒绝记录目录(读写):<容器 id>.jsonl。
11
11
  const CLIENTS_FILE = 'clients.json';
12
12
  const UPSTREAM_FILE = 'upstream.txt';
13
13
  const RELOAD_CHECK_MS = 500;
14
14
 
15
+ /**
16
+ * 运行时注入 sidecar 自己环境的代理(podman containers.conf 的 env / docker config.json 的 proxies)。
17
+ * 只由运行时配置决定,用户容器写不到;环回地址在容器里没有意义,改写成宿主机别名。
18
+ */
19
+ function envUpstream(env = process.env) {
20
+ const text = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy || '';
21
+ if (!text) return '';
22
+ try {
23
+ const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(text) ? text : `http://${text}`);
24
+ if (url.protocol !== 'http:') return '';
25
+ if (/^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(url.hostname)) url.hostname = env.EGRESS_HOST_ALIAS || 'host.containers.internal';
26
+ return url.toString().replace(/\/$/, '');
27
+ } catch (e) {
28
+ return '';
29
+ }
30
+ }
31
+
15
32
  function loadClients(file) {
16
33
  try {
17
34
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
@@ -32,14 +49,15 @@ function loadClients(file) {
32
49
  */
33
50
  async function start(options) {
34
51
  const clientsFile = path.join(options.dataDir, CLIENTS_FILE);
35
- // 上游代理:upstream.txt 变了就热更新(CLI 与 serve 的环境可能不同,不能为此重建容器)
52
+ // 上游代理:upstream.txt(serve / CLI 进程显式配置的)优先,没有就用运行时注入 sidecar 的代理;
53
+ // upstream.txt 变了就热更新(CLI 与 serve 的环境可能不同,不能为此重建容器)
36
54
  const upstreamFile = path.join(options.dataDir, UPSTREAM_FILE);
37
55
  let upstream = '';
38
56
  let upstreamSig = '';
39
57
  let upstreamCheckedAt = 0;
40
58
  const getUpstream = () => {
41
59
  const nowMs = Date.now();
42
- if (nowMs - upstreamCheckedAt < RELOAD_CHECK_MS) return upstream;
60
+ if (nowMs - upstreamCheckedAt < RELOAD_CHECK_MS) return upstream || envUpstream();
43
61
  upstreamCheckedAt = nowMs;
44
62
  try {
45
63
  const stat = fs.statSync(upstreamFile);
@@ -52,7 +70,7 @@ async function start(options) {
52
70
  upstreamSig = '';
53
71
  upstream = '';
54
72
  }
55
- return upstream;
73
+ return upstream || envUpstream();
56
74
  };
57
75
 
58
76
  let clients = loadClients(clientsFile);
@@ -109,4 +127,4 @@ if (require.main === module) {
109
127
  });
110
128
  }
111
129
 
112
- module.exports = { start, CLIENTS_FILE, UPSTREAM_FILE };
130
+ module.exports = { start, envUpstream, CLIENTS_FILE, UPSTREAM_FILE };
@@ -7,8 +7,8 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const { writeConfigFileSecure } = require('./secure-file');
9
9
 
10
- // 域名白名单的强制执行点:manyoyo 网络里的一个专用小容器(sidecar),里面用镜像自带的 node 跑 egress-proxy。
11
- // - 容器按“来源 IP”识别:宿主机在每次下发规则时把「容器 IP → 容器 id + 策略」写进 clients.json(只读挂入),
10
+ // 域名规则的强制执行点:manyoyo 网络里的一个专用小容器(sidecar),里面用镜像自带的 node 跑 egress-proxy。
11
+ // - 容器按“来源 IP”识别:宿主机在每次下发规则时把「容器 IP → 容器 id + 编译好的有序规则」写进 clients.json(只读挂入),
12
12
  // 认不出的来源一律 403;宿主机上不监听任何端口,同机其他用户无法占用或冒充。
13
13
  // - 固定 IP(--ip):重启后不变,容器的代理地址与 nft 规则才能稳定。
14
14
  // - sidecar 只读挂入程序文件和数据目录,只有拒绝记录目录可写;丢掉全部能力。
@@ -123,7 +123,6 @@ function createSidecarManager(options) {
123
123
  const name = sidecarName(homeDir);
124
124
  const hash = homeHash(homeDir);
125
125
  let inflight = null;
126
- let lastClients = '';
127
126
 
128
127
  async function inspectSidecar() {
129
128
  try {
@@ -178,7 +177,7 @@ function createSidecarManager(options) {
178
177
  }
179
178
  let tail = '';
180
179
  try { tail = await run(['logs', '--tail', '5', name]); } catch (e) { /* 取不到就算了 */ }
181
- throw new Error(`域名白名单代理没有起来${tail ? `: ${String(tail).trim().split('\n').slice(-2).join(' ')}` : ''}`);
180
+ throw new Error(`域名规则代理没有起来${tail ? `: ${String(tail).trim().split('\n').slice(-2).join(' ')}` : ''}`);
182
181
  }
183
182
 
184
183
  async function doEnsure() {
@@ -211,7 +210,7 @@ function createSidecarManager(options) {
211
210
  '--pull=never',
212
211
  '--label', 'manyoyo.role=egress', '--label', `manyoyo.home=${hash}`, '--label', `manyoyo.egress.rev=${rev}`,
213
212
  '--volume', `${p.app}:/app:ro`, '--volume', `${p.data}:/data:ro`, '--volume', `${p.denied}:/denied`,
214
- '--env', `EGRESS_PORT=${SIDECAR_PORT}`
213
+ '--env', `EGRESS_PORT=${SIDECAR_PORT}`, '--env', `EGRESS_HOST_ALIAS=${hostAlias}`
215
214
  ];
216
215
  if (isDocker) args.push('--add-host', 'host.docker.internal:host-gateway');
217
216
  args.push('--entrypoint', 'node', options.imageRef(), '/app/egress-sidecar-main.js');
@@ -228,8 +227,8 @@ function createSidecarManager(options) {
228
227
  }
229
228
  }
230
229
  throw new Error(/address already in use|ip.*(used|allocated)|in use/i.test(message) && !/name/i.test(message)
231
- ? `域名白名单代理需要的地址 ${ip} 已被占用(${networkName} 网络里有别的容器用了它)`
232
- : `域名白名单代理启动失败: ${message}`);
230
+ ? `域名规则代理需要的地址 ${ip} 已被占用(${networkName} 网络里有别的容器用了它)`
231
+ : `域名规则代理启动失败: ${message}`);
233
232
  }
234
233
  await waitReady();
235
234
  return { name, ip, port: SIDECAR_PORT };
@@ -247,18 +246,29 @@ function createSidecarManager(options) {
247
246
  return Boolean(info && runningOf(info));
248
247
  }
249
248
 
250
- /** 写入「来源 IP → 容器 id + 策略」;内容没变就不写(避免无谓刷新)。 */
249
+ /** 写入「来源 IP → 容器 id + 编译好的有序规则」;内容没变就不写(避免无谓刷新)。 */
251
250
  function writeClients(clients) {
252
251
  const p = egressPaths(homeDir);
253
252
  ensureDir(p.dir);
254
253
  ensureDir(p.data);
255
254
  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;
255
+ const file = path.join(p.data, 'clients.json');
256
+ // 与磁盘上的内容比较(别的进程——serve / CLI——可能刚改过它),没变就不写
257
+ try { if (fs.readFileSync(file, 'utf-8') === text) return; } catch (e) { /* 还没有就写 */ }
258
+ writeAtomic(file, text, 0o644);
259
259
  }
260
260
 
261
- return { name, ensure, isHealthy, writeClients, paths: () => egressPaths(homeDir), isSidecarName: n => n === name };
261
+ /** 当前 clients.json 里的映射(没有或损坏返回空对象)。 */
262
+ function readClients() {
263
+ try {
264
+ const parsed = JSON.parse(fs.readFileSync(path.join(egressPaths(homeDir).data, 'clients.json'), 'utf-8'));
265
+ return parsed && parsed.clients && typeof parsed.clients === 'object' ? parsed.clients : {};
266
+ } catch (e) {
267
+ return {};
268
+ }
269
+ }
270
+
271
+ return { name, ensure, isHealthy, writeClients, readClients, paths: () => egressPaths(homeDir), isSidecarName: n => n === name };
262
272
  }
263
273
 
264
274
  module.exports = {
@@ -2,13 +2,13 @@
2
2
 
3
3
  const dns = require('dns').promises;
4
4
  const net = require('net');
5
- const { normalizePolicy, normalizeDomain } = require('./network-policy');
5
+ const { normalizePolicy, normalizeDomain, isUnrestricted } = require('./network-policy');
6
6
  const { isRestrictedAddress } = require('./egress-proxy');
7
7
 
8
8
  // 新建容器时从用户在创建时填写的 env 里推断“Agent 要访问的端点”(如 ANTHROPIC_BASE_URL、OPENAI_BASE_URL 指向宿主机上的
9
9
  // Ollama 或局域网网关),把落在私有网段的写进网络策略,否则默认收紧会让 Agent 一上来就连不上自己配置的模型服务。
10
10
  // 只在创建时、只读宿主机侧给定的 env(不读容器可写的 /run/manyoyo/env:容器不能靠改自己的 env 来放开自己的防火墙);
11
- // 结果写进 network.json,在「容器」页的规则里看得到,也可以删。
11
+ // 结果写进 network.json,在「设置容器」页的规则里看得到,也可以删。
12
12
  // 代理变量的主机不算“Agent 要访问的服务”
13
13
  const PROXY_KEYS = new Set(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY']);
14
14
  const HOST_ALIASES = new Set(['host.containers.internal', 'host.docker.internal']);
@@ -26,7 +26,7 @@ function urlOf(value) {
26
26
 
27
27
  /**
28
28
  * env 里 URL 的域名(排除 IP、宿主机别名、代理变量):Agent 的模型服务(ANTHROPIC_BASE_URL、OPENAI_BASE_URL…)。
29
- * 域名白名单模式下它们必须放行,否则 Agent 一发请求就被过滤代理拒绝(客户端只会看到 ERR_PROXY_TUNNEL)。
29
+ * 仅白名单模式下它们必须放行,否则 Agent 一发请求就被过滤代理拒绝(客户端只会看到 ERR_PROXY_TUNNEL)。
30
30
  */
31
31
  function envUrlDomains(envLines) {
32
32
  const out = [];
@@ -76,24 +76,23 @@ async function inferEnvEndpoints(envLines, lookup = async host => (await dns.loo
76
76
  return { host, rules, domains: envUrlDomains(envLines) };
77
77
  }
78
78
 
79
- /** 把推断出的端点并进(已校验的)策略;open 预设不需要。 */
79
+ /**
80
+ * 把推断出的端点并进(已校验的)策略,写成出站规则行:宿主机端口 → 允许 @host,私有地址 → 允许该 IP,
81
+ * 模型服务域名只在「仅白名单」下加(其他模式本来就能访问公网域名)。不限制的自定义策略不需要。
82
+ */
80
83
  async function withEnvEndpoints(policy, envLines, lookup) {
81
- if (policy.preset === 'open') return policy;
84
+ if (isUnrestricted(policy)) return policy;
82
85
  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
- });
86
+ const wanted = [
87
+ ...inferred.host.map(r => ({ target: '@host', ports: r.ports, proto: r.proto })),
88
+ ...inferred.rules.map(r => ({ target: r.cidr, ports: r.ports, proto: r.proto })),
89
+ ...(policy.preset === 'allowlist' ? inferred.domains.map(domain => ({ target: domain, ports: '', proto: 'all' })) : [])
90
+ ];
91
+ const key = r => `${r.target}|${r.ports}|${r.proto}`;
92
+ const have = new Set(policy.outbound.filter(r => r.action === 'allow').map(key));
93
+ const added = wanted.filter(r => !have.has(key(r))).map(r => ({ action: 'allow', ...r, enabled: true }));
94
+ if (!added.length) return policy;
95
+ return normalizePolicy({ ...policy, outbound: [...policy.outbound, ...added] });
97
96
  }
98
97
 
99
98
  module.exports = { inferEnvEndpoints, withEnvEndpoints, envUrlDomains };