@xcanwin/manyoyo 8.5.0 → 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 = {
@@ -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 };
@@ -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 };