@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 CHANGED
@@ -49,7 +49,7 @@ const { applyAptMirror } = require('../lib/mirrors');
49
49
  const containerState = require('../lib/container-state');
50
50
  const { buildExecArgs, resolveContainerId } = require('../lib/container-exec');
51
51
  const { createNetworkManager, NETWORK_NAME } = require('../lib/container-network');
52
- const { normalizePolicy } = require('../lib/network-policy');
52
+ const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../lib/network-policy');
53
53
  const { withEnvEndpoints } = require('../lib/network-endpoints');
54
54
  const { resolveManageOptions } = require('../lib/container-manage-options');
55
55
  const {
@@ -354,7 +354,7 @@ function installServeProcessDiagnostics(logger) {
354
354
  * @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
355
355
  * @property {string} [autostart] - 容器每次启动时由容器内 init 执行的 bash 脚本(新建容器时写入状态目录)
356
356
  * @property {boolean} [autostartOnServe] - serve 启动时自动拉起此容器
357
- * @property {object} [network] - 网络策略(preset / host / egress / peers,见 lib/network-policy.js),不写为默认收紧
357
+ * @property {object} [network] - 网络策略(preset / outbound / inbound / expose,见 lib/network-policy.js),不写为默认收紧
358
358
  * @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
359
359
  * @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
360
360
  * @property {number} [cacheTTL] - 缓存过期天数
@@ -2141,8 +2141,8 @@ async function createNewContainer(runtime) {
2141
2141
 
2142
2142
  await ensureRunImage(runtime);
2143
2143
 
2144
- const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
2145
2144
  const networkManager = createCliNetworkManager(runtime);
2145
+ const networkPolicy = await withEnvEndpoints(await networkManager.resolveRefs(normalizePolicy(runtime.network)), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
2146
2146
  if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(runtime.containerExtraArgs || [])])) {
2147
2147
  try {
2148
2148
  await networkManager.ensureBridgeNetwork();
@@ -2156,7 +2156,7 @@ async function createNewContainer(runtime) {
2156
2156
  envFiles: runtime.envFiles,
2157
2157
  autostart: runtime.autostart,
2158
2158
  network: networkPolicy,
2159
- netRequired: networkPolicy.preset !== 'open',
2159
+ netRequired: !isUnrestrictedPolicy(networkPolicy),
2160
2160
  meta: { name: runtime.containerName }
2161
2161
  });
2162
2162
 
@@ -98,6 +98,11 @@ function parseUrlHostPort(value) {
98
98
  }
99
99
  }
100
100
 
101
+ // 指向宿主机的代理(host.*.internal / loopback)要放行到容器里看到的宿主机 IP,而不是在宿主机上解析
102
+ function isHostTarget(host) {
103
+ return HOST_NAMES.includes(host) || host === 'localhost' || host === '::1' || /^127\./.test(host);
104
+ }
105
+
101
106
  function uniqueBy(items, keyFn) {
102
107
  const seen = new Set();
103
108
  return items.filter(item => {
@@ -148,7 +153,7 @@ function createNetworkManager(options) {
148
153
  });
149
154
  }
150
155
 
151
- // 域名白名单的过滤代理:manyoyo 网络里的 sidecar 容器,按来源 IP 认容器(见 egress-sidecar.js)
156
+ // 域名规则的过滤代理:manyoyo 网络里的 sidecar 容器,按来源 IP 认容器(见 egress-sidecar.js)
152
157
  const sidecar = createSidecarManager({ run, command, homeDir, imageRef: getImageRef, networkName: NETWORK_NAME, ensureNetwork: ensureBridgeNetwork, getUpstream });
153
158
 
154
159
  // buildExecArgs 只需要同步 inspect 取 id;这里 withEnv:false 不会用到
@@ -180,6 +185,13 @@ function createNetworkManager(options) {
180
185
  })).filter(item => state.isValidId(item.id));
181
186
  }
182
187
 
188
+ // 把策略里按名称写的 @container:<名称> 换成 id;名称找不到抛错
189
+ async function resolveRefs(policy) {
190
+ if (!policyLib.hasNameRefs(policy)) return policy;
191
+ const byName = new Map((await listManaged()).map(item => [item.name, item.id]));
192
+ return policyLib.resolveContainerRefs(policy, name => byName.get(name) || '');
193
+ }
194
+
183
195
  function loadPolicy(id) {
184
196
  const raw = state.readNetworkRaw(homeDir, id);
185
197
  return policyLib.normalizePolicy(raw || undefined);
@@ -228,6 +240,29 @@ function createNetworkManager(options) {
228
240
  return await run(args, { timeout: 30000 });
229
241
  }
230
242
 
243
+ // 容器间:别的容器入站规则里允许了本容器(来源写 @containers 或 @container:<本容器>),本容器才能出去到它(派生行)。
244
+ // 来源写 @any 不派生:别的容器开放自己,不能顺带放宽本容器的出站限制
245
+ function computeDerived(self, all) {
246
+ const derived = [];
247
+ for (const other of all) {
248
+ if (other.id === self.id || !other.running || !state.stateExists(homeDir, other.id)) continue;
249
+ let otherPolicy;
250
+ try { otherPolicy = loadPolicy(other.id); } catch (e) { continue; }
251
+ const b = safeBridge(other.info);
252
+ if (!b) continue;
253
+ otherPolicy.inbound.filter(rule => rule.enabled && rule.action === 'allow' && (rule.source === '@containers' || rule.source === `@container:${self.id}`)).forEach(rule => {
254
+ derived.push({ id: other.id, name: other.name, ip: b.ip, ports: rule.ports, proto: rule.proto });
255
+ });
256
+ }
257
+ return derived;
258
+ }
259
+
260
+ async function derivedOf(name) {
261
+ const all = await listManaged();
262
+ const self = all.find(item => item.name === name);
263
+ return self ? computeDerived(self, all) : [];
264
+ }
265
+
231
266
  // 下发时现算:容器看到的宿主机 IP / DNS / 代理 / 镜像源 / peer 的当前 IP
232
267
  async function resolveEndpoints(self, policy, all) {
233
268
  const hostsText = await readRuntimeNetworkFacts(self.info);
@@ -239,10 +274,10 @@ function createNetworkManager(options) {
239
274
  const allow = [];
240
275
  const addHost = (ports, proto) => hostIps.forEach(ip => allow.push({ ip, ports: String(ports), proto }));
241
276
 
242
- // 必需端点:上游代理、过滤代理、Playwright、镜像源
277
+ // 必需端点(@manyoyo):上游代理、过滤代理、Playwright、镜像源
243
278
  const env = envMapOf(self.info);
244
279
  const proxyTargets = [];
245
- // allowlist 下容器只能经过滤代理出网,上游代理不能直连放行(否则绕过域名白名单)
280
+ // allowlist 下容器只能经过滤代理出网,上游代理不能直连放行(否则绕过域名规则)
246
281
  if (policy.preset !== 'allowlist') {
247
282
  Object.keys(env).filter(key => PROXY_ENV_KEYS.has(key)).forEach(key => {
248
283
  const parsed = env[key] && parseUrlHostPort(env[key]);
@@ -254,15 +289,20 @@ function createNetworkManager(options) {
254
289
  if (parsed) proxyTargets.push(parsed);
255
290
  });
256
291
  for (const target of uniqueBy(proxyTargets, t => `${t.host}:${t.port}`)) {
292
+ if (isHostTarget(target.host)) {
293
+ addHost(target.port, 'tcp');
294
+ continue;
295
+ }
257
296
  for (const ip of await resolveHostToIps(target.host)) allow.push({ ip, ports: String(target.port), proto: 'tcp' });
258
297
  }
259
- // 过滤代理(sidecar 容器):allowlist 容器经它出网,只放行到它的 IP:端口;
298
+ // 过滤代理(sidecar 容器):有启用的域名规则时,容器的 HTTP(S) 经它出网,只放行到它的 IP:端口;
260
299
  // 容器发往它的包来源必须是自己的 IP(第二道保险:识别身份靠的就是来源 IP)
261
300
  let managedEnv = [];
262
301
  const antiSpoof = [];
263
- if (policy.preset === 'allowlist' && policy.egress.domains.length) {
302
+ const proxied = policyLib.usesProxy(policy);
303
+ if (proxied) {
264
304
  const selfBridge = safeBridge(self.info);
265
- if (!selfBridge) throw new Error('域名白名单需要容器在 manyoyo 网络里,这个容器的网络不支持');
305
+ if (!selfBridge) throw new Error('域名规则需要容器在 manyoyo 网络里,这个容器的网络不支持');
266
306
  const proxy = await sidecar.ensure();
267
307
  allow.push({ ip: proxy.ip, ports: String(proxy.port), proto: 'tcp' });
268
308
  antiSpoof.push({ daddr: proxy.ip, saddr: selfBridge.ip });
@@ -270,50 +310,55 @@ function createNetworkManager(options) {
270
310
  managedEnv = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].map(key => `${key}=${url}`);
271
311
  }
272
312
  playwrightHostPorts().forEach(port => addHost(port, 'tcp'));
273
- policy.host.forEach(entry => addHost(entry.ports, entry.proto));
274
-
275
- // 容器间:我的 peers.inbound 里的容器能进来;别人的 inbound 列了我,我才能出去
276
- const peerIn = [];
277
- const byId = new Map(all.map(item => [item.id, item]));
278
- policy.peers.inbound.forEach(entry => {
279
- const peer = byId.get(entry.from);
280
- const b = peer && peer.running ? safeBridge(peer.info) : null;
281
- if (b) peerIn.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
313
+
314
+ const containers = {};
315
+ all.forEach(item => {
316
+ const b = item.running ? safeBridge(item.info) : null;
317
+ if (b) containers[item.id] = b.ip;
282
318
  });
283
- for (const other of all) {
284
- if (other.id === self.id || !other.running || !state.stateExists(homeDir, other.id)) continue;
285
- let otherPolicy;
286
- try { otherPolicy = loadPolicy(other.id); } catch (e) { continue; }
287
- const b = safeBridge(other.info);
288
- if (!b) continue;
289
- otherPolicy.peers.inbound.filter(entry => entry.from === self.id).forEach(entry => {
290
- allow.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
291
- });
292
- }
293
- return {
319
+ const derived = computeDerived(self, all);
320
+ derived.forEach(item => allow.push({ ip: item.ip, ports: item.ports, proto: item.proto }));
321
+ const bridge = bridgeOf(self.info);
322
+ const endpoints = {
294
323
  managedEnv,
295
324
  antiSpoof,
296
325
  dns: dnsServers,
297
326
  allow: uniqueBy(allow, a => `${a.ip}|${a.ports}|${a.proto}`),
298
- peerIn,
299
- bridge: bridgeOf(self.info) ? { subnet: bridgeOf(self.info).subnet, gateway: bridgeOf(self.info).gateway } : null
327
+ derived: uniqueBy(derived, d => `${d.id}|${d.ports}|${d.proto}`),
328
+ hostIps,
329
+ containers,
330
+ bridge: bridge ? { subnet: bridge.subnet, gateway: bridge.gateway } : null
300
331
  };
332
+ endpoints.proxyPolicy = proxied ? policyLib.compileProxyPolicy(policy, endpoints) : null;
333
+ return endpoints;
301
334
  }
302
335
 
303
336
  function safeBridge(info) {
304
337
  try { return bridgeOf(info); } catch (e) { return null; }
305
338
  }
306
339
 
307
- // 过滤代理的「来源 IP → 容器 id + 策略」:所有运行中、属于本 HOME、策略是 allowlist 的容器
340
+ // 过滤代理的「来源 IP → 容器 id + 编译好的规则」:所有运行中、属于本 HOME、出站有域名规则的容器。
341
+ // 规则在容器自己下发时编译并缓存;缓存没有(serve 刚重启、CLI 新进程)就沿用 clients.json 里同 id 同 IP 的旧项
342
+ const clientCache = new Map();
343
+ function clientEntryOf(item, previous) {
344
+ const bridge = safeBridge(item.info);
345
+ if (!bridge) return null;
346
+ const cached = clientCache.get(item.id);
347
+ if (cached && cached.ip === bridge.ip) return { ip: bridge.ip, entry: cached.entry };
348
+ const old = previous[bridge.ip];
349
+ return old && old.id === item.id ? { ip: bridge.ip, entry: old } : null;
350
+ }
351
+
308
352
  function syncEgressClients(all) {
353
+ const previous = sidecar.readClients();
309
354
  const clients = {};
310
355
  for (const item of all) {
311
356
  if (!item.running || !state.stateExists(homeDir, item.id)) continue;
312
357
  let policy;
313
358
  try { policy = loadPolicy(item.id); } catch (e) { continue; }
314
- if (policy.preset !== 'allowlist') continue;
315
- const bridge = safeBridge(item.info);
316
- if (bridge) clients[bridge.ip] = { id: item.id, preset: policy.preset, domains: policy.egress.domains, rules: policy.egress.rules };
359
+ if (!policyLib.usesProxy(policy)) continue;
360
+ const found = clientEntryOf(item, previous);
361
+ if (found) clients[found.ip] = found.entry;
317
362
  }
318
363
  sidecar.writeClients(clients);
319
364
  }
@@ -366,10 +411,13 @@ function createNetworkManager(options) {
366
411
  state.writeNetStatus(homeDir, self.id, status);
367
412
  return { ...status, id: self.id };
368
413
  }
369
- state.setNetRequired(homeDir, self.id, policy.preset !== 'open');
414
+ state.setNetRequired(homeDir, self.id, !policyLib.isUnrestricted(policy));
370
415
  const endpoints = await resolveEndpoints(self, policy, all);
371
416
  state.syncFilesEnv(homeDir, self.id); // 环境变量文件的最新快照(供自启动脚本用)
372
417
  state.writeManagedEnv(homeDir, self.id, endpoints.managedEnv);
418
+ const selfBridge = safeBridge(self.info);
419
+ if (endpoints.proxyPolicy && selfBridge) clientCache.set(self.id, { ip: selfBridge.ip, entry: { id: self.id, ...endpoints.proxyPolicy } });
420
+ else clientCache.delete(self.id);
373
421
  syncEgressClients(all); // 先让代理认得这个容器,再放行门闩
374
422
  await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints));
375
423
  await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
@@ -378,7 +426,7 @@ function createNetworkManager(options) {
378
426
  status: 'applied',
379
427
  startedAt,
380
428
  policyHash: policyHashOf(policy),
381
- warning: privileged && policy.preset !== 'open' ? '特权容器(dind / sock 模式)里的进程自带 NET_ADMIN,可以改动这些规则,网络限制没有约束力' : '',
429
+ warning: privileged && !policyLib.isUnrestricted(policy) ? '特权容器(dind / sock 模式)里的进程自带 NET_ADMIN,可以改动这些规则,网络限制没有约束力' : '',
382
430
  at: new Date().toISOString()
383
431
  };
384
432
  state.writeNetStatus(homeDir, self.id, status);
@@ -386,7 +434,7 @@ function createNetworkManager(options) {
386
434
  } catch (e) {
387
435
  const message = `网络规则下发失败: ${e.message}`;
388
436
  try { state.writeNetStatus(homeDir, self.id, { status: 'error', message, startedAt, at: new Date().toISOString() }); } catch (err) { /* 状态目录可能已被删 */ }
389
- // 旧规则可能还在(比如 open → allowlist 失败):清掉门闩,让 manyoyo 的 exec 与自启动都按“未下发”处理
437
+ // 旧规则可能还在(比如不限制 → 收紧失败):清掉门闩,让 manyoyo 的 exec 与自启动都按“未下发”处理
390
438
  await clearGate(name);
391
439
  const error = new Error(message);
392
440
  error.cause = e;
@@ -420,7 +468,7 @@ function createNetworkManager(options) {
420
468
  } catch (e) {
421
469
  policy = null; // 策略文件损坏:按收紧处理,下发时会报错
422
470
  }
423
- if (policy && policy.preset === 'open') return { status: 'open' };
471
+ if (policy && policyLib.isUnrestricted(policy)) return { status: 'unrestricted' };
424
472
  // 判断依据在宿主机侧(net-status.json:同一次启动 + 同一份策略 + 下发成功),不依赖容器里的任何文件或命令
425
473
  const status = state.readNetStatus(homeDir, id);
426
474
  const startedAt = String((info.State && info.State.StartedAt) || '');
@@ -428,20 +476,25 @@ function createNetworkManager(options) {
428
476
  if (sameRun && status.status === 'unsupported') return { status: 'unsupported' };
429
477
  if (policy && sameRun && status.status === 'applied' && status.policyHash === policyHashOf(policy)) {
430
478
  // 规则还在,但过滤代理可能意外退了(容器出网此时是失败即关闭):顺手拉起,失败不拦 exec
431
- if (policy.preset === 'allowlist' && policy.egress.domains.length) {
479
+ if (policyLib.usesProxy(policy)) {
432
480
  try {
433
481
  if (!await sidecar.isHealthy()) {
434
482
  await sidecar.ensure();
435
483
  syncEgressClients(await listManaged());
436
484
  }
437
- } catch (e) { warn(`域名白名单代理恢复失败: ${e.message}`); }
485
+ } catch (e) { warn(`域名规则代理恢复失败: ${e.message}`); }
486
+ // 代理不认识这个容器(规则没缓存、clients.json 里也没有):重新下发一次
487
+ if (!clientEntryOf({ id, info }, sidecar.readClients())) return await apply(name, { expectId: id });
438
488
  }
439
489
  return { status: 'applied' };
440
490
  }
441
491
  return await apply(name, { expectId: id });
442
492
  }
443
493
 
444
- // 与 `name` 有 peers 关系的其他运行中容器(本容器重启后 IP 变化时要重算它们)
494
+ // 与 `name` 有容器间规则关系的其他运行中容器(本容器重启后 IP 变化时要重算它们)
495
+ const rulesOf = policy => [...policy.outbound, ...policy.inbound].filter(rule => rule.enabled);
496
+ const refersTo = (policy, id) => rulesOf(policy).some(rule => (rule.target || rule.source) === `@container:${id}`);
497
+ const opensToContainers = policy => policy.inbound.some(rule => rule.enabled && rule.action === 'allow' && rule.source === '@containers');
445
498
  async function relatedContainers(name) {
446
499
  const all = await listManaged();
447
500
  const self = all.find(item => item.name === name);
@@ -452,7 +505,7 @@ function createNetworkManager(options) {
452
505
  let p;
453
506
  let mine;
454
507
  try { p = loadPolicy(other.id); mine = loadPolicy(self.id); } catch (e) { continue; }
455
- if (p.peers.inbound.some(e => e.from === self.id) || mine.peers.inbound.some(e => e.from === other.id)) related.push(other.name);
508
+ if (refersTo(p, self.id) || refersTo(mine, other.id) || opensToContainers(p) || opensToContainers(mine)) related.push(other.name);
456
509
  }
457
510
  return related;
458
511
  }
@@ -468,22 +521,20 @@ function createNetworkManager(options) {
468
521
  }
469
522
  }
470
523
  // 没有容器需要重新下发规则时,过滤代理也要在(运行时重启后它同样是停着的)
471
- await healSidecar().catch(e => warn(`域名白名单代理恢复失败: ${e.message}`));
524
+ await healSidecar().catch(e => warn(`域名规则代理恢复失败: ${e.message}`));
472
525
  }
473
526
 
474
- // 订阅容器 start 事件:容器被外部重启(podman restart、machine 重启后手动 start)后 netns 重建、规则丢失,
475
- // 事件到来就重新下发,并重算与它有 peers 关系的容器(它的 IP 可能变了)。
476
- async function needsSidecar(all) {
527
+ async function needsSidecar(all) {
477
528
  return all.some(item => {
478
529
  if (!state.stateExists(homeDir, item.id)) return false;
479
530
  try {
480
531
  const policy = loadPolicy(item.id);
481
- return policy.preset === 'allowlist' && policy.egress.domains.length > 0;
532
+ return policyLib.usesProxy(policy);
482
533
  } catch (e) { return false; }
483
534
  });
484
535
  }
485
536
 
486
- // 过滤代理容器自己退出 / 被删:有 allowlist 容器就拉起来,并重写映射
537
+ // 过滤代理容器自己退出 / 被删:有用到它的容器就拉起来,并重写映射
487
538
  let lastHealAt = 0;
488
539
  let healTimer = null;
489
540
  async function healSidecar() {
@@ -494,7 +545,7 @@ function createNetworkManager(options) {
494
545
  if (!healTimer) {
495
546
  healTimer = setTimeout(() => {
496
547
  healTimer = null;
497
- healSidecar().catch(e => warn(`域名白名单代理恢复失败: ${e.message}`));
548
+ healSidecar().catch(e => warn(`域名规则代理恢复失败: ${e.message}`));
498
549
  }, wait + 50);
499
550
  if (healTimer.unref) healTimer.unref();
500
551
  }
@@ -601,7 +652,7 @@ function createNetworkManager(options) {
601
652
  };
602
653
  }
603
654
 
604
- return { NETWORK_NAME, sidecar, run, listManaged, loadPolicy, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
655
+ return { NETWORK_NAME, sidecar, run, listManaged, loadPolicy, resolveRefs, derivedOf, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
605
656
  }
606
657
 
607
658
  module.exports = {
@@ -611,5 +662,6 @@ module.exports = {
611
662
  bridgeOf,
612
663
  parseHostsAndResolv,
613
664
  parseUrlHostPort,
665
+ isHostTarget,
614
666
  createNetworkManager
615
667
  };
@@ -27,7 +27,7 @@ function buildContainerRunArgs(options) {
27
27
  '--name', options.containerName,
28
28
  '--entrypoint', `${SYS_MOUNT}/init.sh`,
29
29
  // docker 默认带 NET_RAW(podman 默认不带):有它的容器能发原始包 / 伪造 ARP,冒充别的容器的来源 IP,
30
- // 域名白名单的过滤代理就是按来源 IP 认容器的,所以统一去掉
30
+ // 域名规则的过滤代理就是按来源 IP 认容器的,所以统一去掉
31
31
  '--cap-drop', 'NET_RAW',
32
32
  ...(options.contModeArgs || []),
33
33
  ...(options.containerExtraArgs || []),
@@ -3,27 +3,43 @@
3
3
  const dns = require('dns').promises;
4
4
  const http = require('http');
5
5
  const net = require('net');
6
- const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
6
+ const { domainMatches, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
7
7
 
8
- // 域名白名单的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
9
- // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按连接的来源 IP 认出是哪个容器(getClient),
10
- // 认不出的来源一律 403;按域名放行 CONNECT / 普通 HTTP,
11
- // 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
12
- // 除非该容器的 egress.rules 显式放行该 IP:端口。
8
+ // 域名规则的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
9
+ // 有启用域名规则的容器,HTTP(S) 都走这个代理,防火墙只放行到它;这里按连接的来源 IP 认出是哪个容器(getClient),
10
+ // 认不出的来源一律 403;然后按该容器编译好的有序规则(域名行 + 地址行 + 模式默认行)从上往下找第一条命中的。
11
+ // 地址行按“任一解析结果”匹配;通配域名命中后,解析结果若落在 @host / @private / @metadata,仍要有一条允许该地址的规则。
12
+ // 环回 / 未指定 / 组播地址永远拒绝(防借代理打 sidecar 自己)。
13
13
  // 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
14
14
  const CONNECT_TIMEOUT_MS = 15000;
15
15
  const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
16
16
  const MAX_TUNNELS_PER_CONTAINER = 256;
17
+ const LOOKUP_TIMEOUT_MS = 5000;
17
18
 
18
- const blocked = new net.BlockList();
19
- [...PRIVATE_V4, '127.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32'].forEach(cidr => {
20
- const [addr, prefix] = cidr.split('/');
21
- blocked.addSubnet(addr, Number(prefix), 'ipv4');
22
- });
23
- [...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128'].forEach(cidr => {
24
- const [addr, prefix] = cidr.split('/');
25
- blocked.addSubnet(addr, Number(prefix), 'ipv6');
26
- });
19
+ function withTimeout(promise, ms) {
20
+ let timer;
21
+ return Promise.race([promise, new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); })]).finally(() => clearTimeout(timer));
22
+ }
23
+
24
+ async function liveOnly(ips) {
25
+ return ips.filter(ip => !checkList(floor, ip));
26
+ }
27
+
28
+ function blockListOf(cidrs) {
29
+ const list = new net.BlockList();
30
+ (cidrs || []).forEach(cidr => {
31
+ const [addr, prefix] = String(cidr).split('/');
32
+ const family = net.isIP(addr);
33
+ if (!family) return;
34
+ const type = family === 4 ? 'ipv4' : 'ipv6';
35
+ if (prefix === undefined) list.addAddress(addr, type);
36
+ else list.addSubnet(addr, Number(prefix), type);
37
+ });
38
+ return list;
39
+ }
40
+
41
+ const blocked = blockListOf([...PRIVATE_V4, ...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128', '127.0.0.0/8', '255.255.255.255/32']);
42
+ const floor = blockListOf(['127.0.0.0/8', '0.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32', '::1/128', '::/128', 'ff00::/8']);
27
43
 
28
44
  // ::ffff:a.b.c.d 与 NAT64(64:ff9b::/96)里嵌的 IPv4 要按 IPv4 判断
29
45
  function embeddedV4(ip) {
@@ -39,29 +55,102 @@ function embeddedV4(ip) {
39
55
  return '';
40
56
  }
41
57
 
58
+ function checkList(list, ip) {
59
+ const addr = embeddedV4(ip) || ip;
60
+ const family = net.isIP(addr);
61
+ return family ? list.check(addr, family === 4 ? 'ipv4' : 'ipv6') : null;
62
+ }
63
+
42
64
  function isRestrictedAddress(ip) {
43
- const v4 = embeddedV4(ip);
44
- if (v4) return blocked.check(v4, 'ipv4');
45
- const family = net.isIP(ip);
46
- if (!family) return true;
47
- return blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6');
65
+ const hit = checkList(blocked, ip);
66
+ return hit === null ? true : hit;
48
67
  }
49
68
 
50
- function ruleCovers(rule, ip, port) {
51
- if (rule.proto && rule.proto !== 'tcp') return false;
52
- if (rule.ports) {
53
- const ok = rule.ports.split(',').some(item => {
54
- const [a, b] = item.split('-').map(Number);
55
- return port >= a && port <= (b === undefined ? a : b);
56
- });
57
- if (!ok) return false;
69
+ const setCache = new WeakMap();
70
+ function compiledSet(set) {
71
+ let compiled = setCache.get(set);
72
+ if (!compiled) {
73
+ compiled = { inc: blockListOf(set.inc), exc: blockListOf(set.exc) };
74
+ setCache.set(set, compiled);
58
75
  }
59
- const [addr, prefixText] = rule.cidr.split('/');
60
- const family = net.isIP(addr);
61
- if (!family || net.isIP(ip) !== family) return false;
62
- const list = new net.BlockList();
63
- list.addSubnet(addr, prefixText === undefined ? (family === 4 ? 32 : 128) : Number(prefixText), family === 4 ? 'ipv4' : 'ipv6');
64
- return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
76
+ return compiled;
77
+ }
78
+
79
+ /** 地址集合 {any, inc, exc} 是否包含 ip。 */
80
+ function setCovers(set, ip) {
81
+ if (!set) return false;
82
+ const compiled = compiledSet(set);
83
+ if (!set.any && !checkList(compiled.inc, ip)) return false;
84
+ return !checkList(compiled.exc, ip);
85
+ }
86
+
87
+ const listCache = new WeakMap();
88
+ function listCovers(cidrs, ip) {
89
+ let list = listCache.get(cidrs);
90
+ if (!list) {
91
+ list = blockListOf(cidrs);
92
+ listCache.set(cidrs, list);
93
+ }
94
+ return Boolean(checkList(list, ip));
95
+ }
96
+
97
+ function portsCover(ports, port) {
98
+ if (!ports) return true;
99
+ return String(ports).split(',').some(item => {
100
+ const [a, b] = item.split('-').map(Number);
101
+ return port >= a && port <= (b === undefined ? a : b);
102
+ });
103
+ }
104
+
105
+ /**
106
+ * 按容器编译好的有序规则判断一次连接。解析是惰性的:只有规则需要看地址时才查 DNS(被域名规则直接拒绝 / 放行的不用等 DNS)。
107
+ * @param {{rules: object[], default: string, sensitive?: string[]}} policy
108
+ * @param {string} host 目标主机(域名或 IP 字面量)
109
+ * @param {number} port
110
+ * @param {string[]|(() => Promise<string[]>)} ipsOrResolve 目标的解析结果,或惰性解析函数(目标本身是 IP 时就是它自己;解析不出来为空)
111
+ * @returns {Promise<{ok: boolean, reason?: string, usable?: string[]|null}>} usable 为 null 表示判断时没看地址,由调用方按解析结果挑
112
+ */
113
+ async function decide(policy, host, port, ipsOrResolve) {
114
+ const isIp = net.isIP(host) !== 0;
115
+ const deny = reason => ({ ok: false, reason });
116
+ let live = null;
117
+ const liveIps = async () => {
118
+ if (live === null) {
119
+ const ips = Array.isArray(ipsOrResolve) ? ipsOrResolve : (isIp ? [host] : await ipsOrResolve());
120
+ live = { all: ips, ok: ips.filter(ip => !checkList(floor, ip)) };
121
+ }
122
+ return live;
123
+ };
124
+ const applies = rule => rule.proto !== 'udp' && portsCover(rule.ports, port);
125
+ const addressAllowed = ip => {
126
+ for (const rule of policy.rules) {
127
+ if (rule.domain || !applies(rule) || !setCovers(rule.addr, ip)) continue;
128
+ return rule.action === 'allow';
129
+ }
130
+ return policy.default === 'allow';
131
+ };
132
+ for (const rule of policy.rules) {
133
+ if (!applies(rule)) continue;
134
+ if (rule.domain) {
135
+ if (isIp || !domainMatches(rule.domain, host)) continue;
136
+ if (rule.action === 'deny') return deny('domain');
137
+ if (rule.domain.startsWith('*.')) {
138
+ const { all, ok } = await liveIps();
139
+ if (all.length && !ok.length) return deny('address');
140
+ const sensitive = policy.sensitive || [];
141
+ const usable = ok.filter(ip => !listCovers(sensitive, ip) || addressAllowed(ip));
142
+ if (ok.length && !usable.length) return deny('address');
143
+ return { ok: true, usable };
144
+ }
145
+ return { ok: true, usable: null };
146
+ }
147
+ const { all, ok } = await liveIps();
148
+ if (all.length && !ok.length) return deny('address');
149
+ const hit = ok.filter(ip => setCovers(rule.addr, ip));
150
+ if (!hit.length) continue;
151
+ return rule.action === 'allow' ? { ok: true, usable: isIp ? ok : hit } : deny('address');
152
+ }
153
+ return policy.default === 'allow' ? { ok: true, usable: null } : deny('domain');
65
154
  }
66
155
 
67
156
  function parseTarget(text) {
@@ -74,14 +163,16 @@ function parseTarget(text) {
74
163
 
75
164
  /**
76
165
  * @param {object} options
77
- * @param {(ip: string) => {id: string, preset: string, domains: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
166
+ * @param {(ip: string) => {id: string, default: string, sensitive?: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
78
167
  * @param {(event: {id: string, host: string, port: number, reason: string}) => void} [options.onDenied] 拒绝记录
79
168
  * @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
80
169
  * @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
170
+ * @param {(ip: string) => string} [options.mapAddress] 测试替身:实际拨号的地址(单测里把虚构的公网 IP 映射到本机夹具)
81
171
  */
82
172
  function createEgressProxy(options) {
83
173
  const getClient = options.getClient;
84
174
  const onDenied = options.onDenied || (() => {});
175
+ const mapAddress = options.mapAddress || (ip => ip);
85
176
  const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
86
177
  // upstream 可以是字符串或返回字符串的函数(sidecar 里按文件改动热更新);解析结果按字符串缓存
87
178
  let parsedFor = null;
@@ -120,33 +211,24 @@ function createEgressProxy(options) {
120
211
 
121
212
  // 返回 {ok:true, ip?} 或 {ok:false, status, message}
122
213
  async function authorize(policy, host, port, upstream) {
123
- if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'domain' };
214
+ if (!policy || !Array.isArray(policy.rules)) return { ok: false, status: 403, message: 'domain' };
124
215
  const isIp = net.isIP(host) !== 0;
125
- const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
126
- if (isIp) {
127
- if (isRestrictedAddress(host) && !explicit(host, port)) return { ok: false, status: 403, message: 'address' };
128
- if (!explicit(host, port)) return { ok: false, status: 403, message: 'domain' };
129
- return { ok: true, ip: host };
130
- }
131
- if (!domainAllowed(policy.domains, host)) return { ok: false, status: 403, message: 'domain' };
132
- if (upstream) {
133
- // 经上游代理时真正的连接由上游解析;这里仍做一次本地解析,解析得到私有 / 环回地址(且没有被显式放行)就拒绝。
134
- // 本地解析不出来(宿主机不一定能解析)则放行给上游。残余风险:白名单里的通配域名若被控制了 DNS,
135
- // 本地与上游可能得到不同答案——不要在白名单里放你不信任的通配域名。
136
- try {
137
- const local = await lookup(host);
138
- if (local.length && local.every(ip => isRestrictedAddress(ip) && !explicit(ip, port))) return { ok: false, status: 403, message: 'address' };
139
- } catch (e) { /* 本地解析失败:交给上游 */ }
140
- return { ok: true, ip: '' };
141
- }
142
- let addresses;
143
- try {
144
- addresses = await lookup(host);
145
- } catch (e) {
146
- return { ok: false, status: 502, message: 'dns' };
147
- }
148
- const usable = addresses.filter(ip => !isRestrictedAddress(ip) || explicit(ip, port));
149
- if (!usable.length) return { ok: false, status: 403, message: 'address' };
216
+ let addresses = null;
217
+ const resolveIps = async () => {
218
+ if (addresses === null) {
219
+ addresses = [];
220
+ try {
221
+ addresses = await withTimeout(lookup(host), LOOKUP_TIMEOUT_MS);
222
+ } catch (e) { /* 解析不出来:规则里的域名行仍然判断;经上游代理时交给上游解析 */ }
223
+ }
224
+ return addresses;
225
+ };
226
+ const verdict = await decide(policy, host, port, isIp ? [host] : resolveIps);
227
+ if (!verdict.ok) return { ok: false, status: 403, message: verdict.reason };
228
+ if (upstream) return { ok: true, ip: '' };
229
+ // 直接拨号:需要地址。规则没看过地址时,从解析结果里挑非环回的
230
+ const usable = verdict.usable || (await liveOnly(isIp ? [host] : await resolveIps()));
231
+ if (!usable.length) return isIp || (addresses && addresses.length) ? { ok: false, status: 403, message: 'address' } : { ok: false, status: 502, message: 'dns' };
150
232
  return { ok: true, ip: usable[0] };
151
233
  }
152
234
 
@@ -234,7 +316,7 @@ function createEgressProxy(options) {
234
316
  upstreamSocket.on('data', onData);
235
317
  });
236
318
  } else {
237
- upstreamSocket = track(net.connect({ host: verdict.ip, port: target.port }));
319
+ upstreamSocket = track(net.connect({ host: mapAddress(verdict.ip), port: target.port }));
238
320
  upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
239
321
  upstreamSocket.once('connect', () => {
240
322
  upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
@@ -271,7 +353,7 @@ function createEgressProxy(options) {
271
353
  delete headers['proxy-connection'];
272
354
  const outbound = upstream
273
355
  ? { host: upstream.host, port: upstream.port, path: req.url, headers: { ...headers, ...(upstream.auth ? { 'proxy-authorization': upstream.auth } : {}) } }
274
- : { host: verdict.ip, port, path: `${url.pathname}${url.search}`, headers };
356
+ : { host: mapAddress(verdict.ip), port, path: `${url.pathname}${url.search}`, headers };
275
357
  const proxyReq = http.request({ ...outbound, method: req.method, timeout: CONNECT_TIMEOUT_MS });
276
358
  let released = false;
277
359
  const done = () => { if (!released) { released = true; release(id); } };
@@ -312,4 +394,4 @@ function createEgressProxy(options) {
312
394
  return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
313
395
  }
314
396
 
315
- module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseTarget };
397
+ module.exports = { createEgressProxy, isRestrictedAddress, decide, setCovers, parseTarget };