@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.
- package/bin/manyoyo.js +4 -4
- package/lib/container-exec.js +20 -1
- 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 +84 -30
- package/lib/web/index.html +81 -82
- package/lib/web/server.js +103 -25
- package/manyoyo.example.json +9 -6
- package/package.json +1 -1
|
@@ -4,49 +4,82 @@ const os = require('os');
|
|
|
4
4
|
const net = require('net');
|
|
5
5
|
const state_ = require('../container-state');
|
|
6
6
|
const { resolveContainerId } = require('../container-exec');
|
|
7
|
-
const { normalizePolicy } = require('../network-policy');
|
|
7
|
+
const { normalizePolicy, resolveContainerRefs, isUnrestricted, domainRuleAllows, targetKind } = require('../network-policy');
|
|
8
8
|
const { parseEnvEntry } = require('../runtime-normalizers');
|
|
9
9
|
const { envUrlDomains } = require('../network-endpoints');
|
|
10
10
|
const { egressPaths } = require('../egress-sidecar');
|
|
11
11
|
const deniedLib = require('../egress-denied');
|
|
12
12
|
const { isBrowserBackground } = require('../browser-background');
|
|
13
|
-
const { domainAllowed } = require('../network-policy');
|
|
14
13
|
|
|
15
14
|
// 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
|
|
16
15
|
// 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
|
|
17
16
|
const LEGACY_MESSAGE = '该容器创建于旧版本,重建后可用';
|
|
18
17
|
|
|
18
|
+
// 本机可用于访问端口暴露的 IPv4 地址(绑定 0.0.0.0 时前端据此列出全部访问链接);
|
|
19
|
+
// 回环排第一,容器 / 虚拟网桥的地址对访问者没用,过滤掉
|
|
20
|
+
const VIRTUAL_IFACE_RE = /^(docker|br-|veth|cni|podman|virbr|lo$)/;
|
|
21
|
+
function listHostIps() {
|
|
22
|
+
const ips = ['127.0.0.1'];
|
|
23
|
+
const ifaces = os.networkInterfaces();
|
|
24
|
+
Object.keys(ifaces).forEach(name => {
|
|
25
|
+
if (VIRTUAL_IFACE_RE.test(name)) return;
|
|
26
|
+
(ifaces[name] || []).forEach(addr => {
|
|
27
|
+
if (addr.family === 'IPv4' && !addr.internal && !ips.includes(addr.address)) ips.push(addr.address);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return ips;
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
function isLoopbackBind(bind) {
|
|
20
34
|
return bind === '127.0.0.1' || bind === '::1';
|
|
21
35
|
}
|
|
22
36
|
|
|
23
|
-
//
|
|
37
|
+
// `wide`:新增的“允许”规则范围极宽,效果接近不设限:任何地址 / 内网 / 元数据、其他容器(入站)、宿主机全部端口、
|
|
38
|
+
// /8 以上的网段(含 0.0.0.0/0)、公共后缀通配(*.co.uk 之类)
|
|
24
39
|
const PUBLIC_SUFFIX_WILDCARD_RE = /^\*\.(co|com|net|org|gov|edu|ac|or|ne|go)\.[a-z]{2}$/;
|
|
40
|
+
const WIDE_OUTBOUND_VARS = ['@any', '@private', '@metadata'];
|
|
41
|
+
const WIDE_INBOUND_VARS = ['@any', '@containers'];
|
|
25
42
|
|
|
26
|
-
function
|
|
43
|
+
function isWideCidr(cidr) {
|
|
27
44
|
const m = /\/(\d+)$/.exec(cidr);
|
|
28
45
|
const prefix = m ? Number(m[1]) : 32;
|
|
29
46
|
return cidr.includes(':') ? prefix <= 16 : prefix <= 8;
|
|
30
47
|
}
|
|
31
48
|
|
|
49
|
+
function isWideRule(rule, direction) {
|
|
50
|
+
const target = direction === 'inbound' ? rule.source : rule.target;
|
|
51
|
+
if (rule.action !== 'allow' || !rule.enabled) return false;
|
|
52
|
+
const kind = targetKind(target);
|
|
53
|
+
if (kind === 'var') return (direction === 'inbound' ? WIDE_INBOUND_VARS : WIDE_OUTBOUND_VARS).includes(target) || (direction !== 'inbound' && target === '@host' && !rule.ports);
|
|
54
|
+
if (kind === 'cidr') return isWideCidr(target);
|
|
55
|
+
return PUBLIC_SUFFIX_WILDCARD_RE.test(target);
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
function wideKeys(policy) {
|
|
59
|
+
const key = (rule, direction) => `${direction}:${rule.action}|${direction === 'inbound' ? rule.source : rule.target}|${rule.ports}|${rule.proto}`;
|
|
33
60
|
return [
|
|
34
|
-
...policy.
|
|
35
|
-
...policy.
|
|
61
|
+
...policy.outbound.filter(rule => isWideRule(rule, 'outbound')).map(rule => key(rule, 'outbound')),
|
|
62
|
+
...policy.inbound.filter(rule => isWideRule(rule, 'inbound')).map(rule => key(rule, 'inbound'))
|
|
36
63
|
];
|
|
37
64
|
}
|
|
38
65
|
|
|
39
|
-
//
|
|
66
|
+
// 需要二次确认的风险:切到自定义、新增非 loopback 的端口暴露、新增范围极宽的允许规则(自定义里本来就不设限,不再逐条问)
|
|
40
67
|
function pendingRisks(before, after) {
|
|
41
68
|
const risks = [];
|
|
42
|
-
if (after.preset === '
|
|
69
|
+
if (after.preset === 'custom' && before.preset !== 'custom') risks.push('custom');
|
|
43
70
|
const hadWide = new Set(wideKeys(before));
|
|
44
|
-
if (after.preset !== '
|
|
71
|
+
if (after.preset !== 'custom' && wideKeys(after).some(key => !hadWide.has(key))) risks.push('wide');
|
|
45
72
|
const had = new Set(before.expose.filter(e => !isLoopbackBind(e.bind)).map(e => `${e.bind}:${e.hostPort}`));
|
|
46
73
|
if (after.expose.some(e => !isLoopbackBind(e.bind) && !had.has(`${e.bind}:${e.hostPort}`))) risks.push('publicBind');
|
|
47
74
|
return risks;
|
|
48
75
|
}
|
|
49
76
|
|
|
77
|
+
function peerIp(info) {
|
|
78
|
+
const networks = (info && info.NetworkSettings && info.NetworkSettings.Networks) || {};
|
|
79
|
+
const pick = networks.manyoyo || Object.values(networks).find(n => n && n.IPAddress);
|
|
80
|
+
return (pick && pick.IPAddress) || '';
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
function parseTail(value, fallback = 16 * 1024) {
|
|
51
84
|
const n = Number(value);
|
|
52
85
|
if (!Number.isInteger(n) || n < 1) return fallback;
|
|
@@ -79,6 +112,16 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
79
112
|
return { name, id };
|
|
80
113
|
}
|
|
81
114
|
|
|
115
|
+
// 名称 → 受管容器 id(只认运行时里真实存在的)
|
|
116
|
+
async function containerIdLookup() {
|
|
117
|
+
try {
|
|
118
|
+
const byName = new Map((await getNetworkManager().listManaged()).map(item => [item.name, item.id]));
|
|
119
|
+
return name => byName.get(name) || '';
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return () => '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
82
125
|
function readPolicy(id) {
|
|
83
126
|
return normalizePolicy(state_.readNetworkRaw(homeDir, id) || undefined);
|
|
84
127
|
}
|
|
@@ -125,7 +168,7 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
125
168
|
function deniedPayload(id, policy) {
|
|
126
169
|
try {
|
|
127
170
|
return deniedLib.read(egressPaths(homeDir).denied, id)
|
|
128
|
-
.filter(record => !
|
|
171
|
+
.filter(record => !domainRuleAllows(policy, record.host))
|
|
129
172
|
.map(record => ({ ...record, background: isBrowserBackground(record.host) }));
|
|
130
173
|
} catch (e) {
|
|
131
174
|
return [];
|
|
@@ -137,17 +180,23 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
137
180
|
const manager = getNetworkManager();
|
|
138
181
|
let peers = [];
|
|
139
182
|
try {
|
|
140
|
-
peers = (await manager.listManaged()).filter(item => item.id !== target.id).map(item => ({ id: item.id, name: item.name, running: item.running }));
|
|
183
|
+
peers = (await manager.listManaged()).filter(item => item.id !== target.id).map(item => ({ id: item.id, name: item.name, running: item.running, ip: (item.running && peerIp(item.info)) || '' }));
|
|
141
184
|
} catch (e) {
|
|
142
185
|
peers = [];
|
|
143
186
|
}
|
|
187
|
+
let derived = [];
|
|
188
|
+
try {
|
|
189
|
+
derived = typeof manager.derivedOf === 'function' ? await manager.derivedOf(target.name) : [];
|
|
190
|
+
} catch (e) {
|
|
191
|
+
derived = [];
|
|
192
|
+
}
|
|
144
193
|
// 建议放行的域名:来自当前环境变量(含容器里改过的)里的 URL。只是建议,要用户自己点才会加进策略
|
|
145
194
|
// (容器可写的 env 不能直接决定防火墙)
|
|
146
195
|
const envLines = [
|
|
147
196
|
...state_.readEnv(homeDir, target.id).entries.map(entry => `${entry.key}=${entry.value}`),
|
|
148
197
|
...state_.envFileEntries(homeDir, target.id).map(entry => `${entry.key}=${entry.value}`)
|
|
149
198
|
];
|
|
150
|
-
const suggestedDomains = envUrlDomains(envLines).filter(domain => !policy
|
|
199
|
+
const suggestedDomains = envUrlDomains(envLines).filter(domain => !domainRuleAllows(policy, domain));
|
|
151
200
|
return {
|
|
152
201
|
legacy: false,
|
|
153
202
|
id: target.id,
|
|
@@ -157,21 +206,26 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
157
206
|
policy,
|
|
158
207
|
status: state_.readNetStatus(homeDir, target.id),
|
|
159
208
|
forwards: getPortForwarder().list(target.id),
|
|
209
|
+
hostIps: listHostIps(),
|
|
160
210
|
peers,
|
|
211
|
+
derived,
|
|
212
|
+
unrestricted: isUnrestricted(policy),
|
|
161
213
|
...extra
|
|
162
214
|
};
|
|
163
215
|
}
|
|
164
216
|
|
|
165
217
|
// 保存策略后:下发规则(容器在运行时)、同步端口转发、重算有 peers 关系的容器
|
|
166
|
-
async function applyPolicy(target, policy) {
|
|
218
|
+
async function applyPolicy(target, policy, relatedBefore = []) {
|
|
167
219
|
const out = { applyError: '', forwardFailures: [] };
|
|
168
220
|
const running = isRunning(target.name);
|
|
169
221
|
if (running) {
|
|
170
222
|
const manager = getNetworkManager();
|
|
171
223
|
try {
|
|
172
224
|
await manager.apply(target.name, { expectId: target.id });
|
|
173
|
-
|
|
174
|
-
|
|
225
|
+
// 旧规则关联的容器也要重算:撤掉“允许 @container:Y”后,Y 出站里原来的派生行得去掉
|
|
226
|
+
const related = [...new Set([...relatedBefore, ...await manager.relatedContainers(target.name)])];
|
|
227
|
+
for (const other of related) {
|
|
228
|
+
await manager.apply(other).catch(e => ctx.logger.warn('related container network apply failed', { related: other, message: e && e.message }));
|
|
175
229
|
}
|
|
176
230
|
} catch (e) {
|
|
177
231
|
out.applyError = e.message;
|
|
@@ -187,21 +241,25 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
187
241
|
return;
|
|
188
242
|
}
|
|
189
243
|
const before = readPolicy(target.id);
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
244
|
+
const normalized = normalizePolicy({ ...body.policy, autostartOnServe: body.policy && body.policy.autostartOnServe !== undefined ? body.policy.autostartOnServe : before.autostartOnServe });
|
|
245
|
+
let next;
|
|
246
|
+
try {
|
|
247
|
+
next = resolveContainerRefs(normalized, await containerIdLookup());
|
|
248
|
+
} catch (e) {
|
|
249
|
+
sendJson(res, 400, { error: e.message });
|
|
250
|
+
return;
|
|
196
251
|
}
|
|
197
252
|
const risks = pendingRisks(before, next);
|
|
198
253
|
if (risks.length && body.confirmRisk !== true) {
|
|
199
254
|
sendJson(res, 400, { error: '该改动会放开网络限制,需要确认', needsConfirm: true, risks });
|
|
200
255
|
return;
|
|
201
256
|
}
|
|
257
|
+
// 保存前先取旧规则关联的容器(保存后旧规则就读不到了)
|
|
258
|
+
let relatedBefore = [];
|
|
259
|
+
try { relatedBefore = isRunning(target.name) ? await getNetworkManager().relatedContainers(target.name) : []; } catch (e) { relatedBefore = []; }
|
|
202
260
|
writePolicy(target.id, next);
|
|
203
|
-
state_.setNetRequired(homeDir, target.id, next
|
|
204
|
-
const result = await applyPolicy(target, next);
|
|
261
|
+
state_.setNetRequired(homeDir, target.id, !isUnrestricted(next));
|
|
262
|
+
const result = await applyPolicy(target, next, relatedBefore);
|
|
205
263
|
const payload = await networkPayload(target, { applyError: result.applyError, forwardFailures: result.forwardFailures });
|
|
206
264
|
// 策略已保存;规则下发失败(502)或端口暴露没起来(409,如端口被占)都要明确报错
|
|
207
265
|
if (result.applyError) sendJson(res, 502, { error: result.applyError, ...payload });
|
|
@@ -209,16 +267,12 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
209
267
|
else sendJson(res, 200, payload);
|
|
210
268
|
}
|
|
211
269
|
|
|
212
|
-
//
|
|
270
|
+
// 一键放行被拦截的域名:在出站规则最前面插入“允许 <域名>”并立即下发(任何模式都可以)
|
|
213
271
|
async function postAllow(target, body) {
|
|
214
272
|
const domain = String(body.domain || '').trim().toLowerCase();
|
|
215
273
|
const policy = readPolicy(target.id);
|
|
216
|
-
if (policy
|
|
217
|
-
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (!policy.egress.domains.includes(domain)) {
|
|
221
|
-
await putNetwork(target, { policy: { ...policy, egress: { ...policy.egress, domains: [...policy.egress.domains, domain] } }, confirmRisk: body.confirmRisk });
|
|
274
|
+
if (!domainRuleAllows(policy, domain)) {
|
|
275
|
+
await putNetwork(target, { policy: { ...policy, outbound: [{ action: 'allow', target: domain, ports: '', proto: 'all', enabled: true }, ...policy.outbound] }, confirmRisk: body.confirmRisk });
|
|
222
276
|
return;
|
|
223
277
|
}
|
|
224
278
|
sendJson(res, 200, await networkPayload(target));
|