@xcanwin/manyoyo 8.5.1 → 8.6.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.
@@ -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 };