@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.
- package/bin/manyoyo.js +4 -4
- package/lib/container-network.js +100 -48
- package/lib/container-run.js +1 -1
- package/lib/egress-proxy.js +146 -64
- package/lib/egress-sidecar-main.js +23 -5
- package/lib/egress-sidecar.js +22 -12
- package/lib/network-endpoints.js +18 -19
- package/lib/network-policy.js +296 -77
- package/lib/web/AGENTS.md +1 -1
- package/lib/web/container-manage.js +68 -30
- package/lib/web/index.html +80 -81
- package/lib/web/server.js +12 -18
- package/manyoyo.example.json +9 -6
- package/package.json +1 -1
|
@@ -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 +
|
|
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
|
|
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 };
|
package/lib/egress-sidecar.js
CHANGED
|
@@ -7,8 +7,8 @@ const os = require('os');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { writeConfigFileSecure } = require('./secure-file');
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
// - 容器按“来源 IP”识别:宿主机在每次下发规则时把「容器 IP → 容器 id +
|
|
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(
|
|
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
|
-
?
|
|
232
|
-
:
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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 = {
|
package/lib/network-endpoints.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* 把推断出的端点并进(已校验的)策略,写成出站规则行:宿主机端口 → 允许 @host,私有地址 → 允许该 IP,
|
|
81
|
+
* 模型服务域名只在「仅白名单」下加(其他模式本来就能访问公网域名)。不限制的自定义策略不需要。
|
|
82
|
+
*/
|
|
80
83
|
async function withEnvEndpoints(policy, envLines, lookup) {
|
|
81
|
-
if (policy
|
|
84
|
+
if (isUnrestricted(policy)) return policy;
|
|
82
85
|
const inferred = await inferEnvEndpoints(envLines, lookup);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 };
|