@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.
@@ -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
- // 把 `wide` 当作放开限制:整个 /8 以上的网段(含 0.0.0.0/0)、公共后缀通配(*.co.uk 之类),效果接近 open
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 isWideRule(cidr) {
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.egress.rules.filter(rule => isWideRule(rule.cidr)).map(rule => `rule:${rule.cidr}|${rule.ports}|${rule.proto}`),
35
- ...policy.egress.domains.filter(domain => PUBLIC_SUFFIX_WILDCARD_RE.test(domain)).map(domain => `domain:${domain}`)
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
- // 需要二次确认的风险:切到 open(等于不加规则)、新增非 loopback 的端口暴露、新增范围极宽的出站规则
66
+ // 需要二次确认的风险:切到自定义、新增非 loopback 的端口暴露、新增范围极宽的允许规则(自定义里本来就不设限,不再逐条问)
40
67
  function pendingRisks(before, after) {
41
68
  const risks = [];
42
- if (after.preset === 'open' && before.preset !== 'open') risks.push('open');
69
+ if (after.preset === 'custom' && before.preset !== 'custom') risks.push('custom');
43
70
  const hadWide = new Set(wideKeys(before));
44
- if (after.preset !== 'open' && wideKeys(after).some(key => !hadWide.has(key))) risks.push('wide');
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 => !domainAllowed(policy.egress.domains, record.host))
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.egress.domains.includes(domain));
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
- for (const related of await manager.relatedContainers(target.name)) {
174
- await manager.apply(related).catch(e => ctx.logger.warn('related container network apply failed', { related, message: e && e.message }));
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 next = normalizePolicy({ ...body.policy, autostartOnServe: body.policy && body.policy.autostartOnServe !== undefined ? body.policy.autostartOnServe : before.autostartOnServe });
191
- for (const entry of next.peers.inbound) {
192
- if (entry.from === target.id || !state_.stateExists(homeDir, entry.from)) {
193
- sendJson(res, 400, { error: `peers.inbound 里的容器不存在: ${entry.from}` });
194
- return;
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.preset !== 'open');
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.preset !== 'allowlist') {
217
- sendJson(res, 400, { error: '只有「仅白名单」出站模式才需要放行域名' });
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));