@xcanwin/manyoyo 8.4.0 → 8.5.1

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.
@@ -11,6 +11,7 @@ const path = require('path');
11
11
  const state = require('./container-state');
12
12
  const { buildExecArgs } = require('./container-exec');
13
13
  const policyLib = require('./network-policy');
14
+ const { createSidecarManager } = require('./egress-sidecar');
14
15
 
15
16
  // 容器网络:把策略翻译成 nft 规则,用一次性 helper 容器(--network container:<名> + NET_ADMIN)写进
16
17
  // 目标容器自己的 netns。Agent 没有 NET_ADMIN,改不掉;规则热更新,不重启。
@@ -113,7 +114,7 @@ function createNetworkManager(options) {
113
114
  const runtimeEnv = options.env ? { ...process.env, ...options.env } : process.env;
114
115
  const getImageRef = typeof options.imageRef === 'function' ? options.imageRef : () => options.imageRef;
115
116
  const getMirrorUrls = options.getMirrorUrls || (() => []);
116
- const getFilterProxy = options.getFilterProxy || (() => null);
117
+ const getUpstream = options.getUpstream || (() => process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || '');
117
118
  const warn = options.warn || (() => {});
118
119
  const locks = new Map();
119
120
 
@@ -147,6 +148,9 @@ function createNetworkManager(options) {
147
148
  });
148
149
  }
149
150
 
151
+ // 域名白名单的过滤代理:manyoyo 网络里的 sidecar 容器,按来源 IP 认容器(见 egress-sidecar.js)
152
+ const sidecar = createSidecarManager({ run, command, homeDir, imageRef: getImageRef, networkName: NETWORK_NAME, ensureNetwork: ensureBridgeNetwork, getUpstream });
153
+
150
154
  // buildExecArgs 只需要同步 inspect 取 id;这里 withEnv:false 不会用到
151
155
  const execCtx = { homeDir, dockerExecArgs: () => '' };
152
156
 
@@ -252,14 +256,17 @@ function createNetworkManager(options) {
252
256
  for (const target of uniqueBy(proxyTargets, t => `${t.host}:${t.port}`)) {
253
257
  for (const ip of await resolveHostToIps(target.host)) allow.push({ ip, ports: String(target.port), proto: 'tcp' });
254
258
  }
255
- // 过滤代理(serve 进程内):allowlist 容器经它出网;宿主机 IP 取容器视角的 host.containers.internal
259
+ // 过滤代理(sidecar 容器):allowlist 容器经它出网,只放行到它的 IP:端口;
260
+ // 容器发往它的包来源必须是自己的 IP(第二道保险:识别身份靠的就是来源 IP)
256
261
  let managedEnv = [];
262
+ const antiSpoof = [];
257
263
  if (policy.preset === 'allowlist' && policy.egress.domains.length) {
258
- const filter = await getFilterProxy(self.id);
259
- if (!filter || !filter.port) throw new Error('域名白名单需要 manyoyo serve 运行(过滤代理在 serve 进程里)');
260
- if (!hostIps.length) throw new Error('容器里找不到宿主机地址(host.containers.internal / 网关),无法使用域名白名单');
261
- addHost(filter.port, 'tcp');
262
- const url = `http://${self.id}:${state.ensureEgressToken(homeDir, self.id)}@${hostIps[0]}:${filter.port}`;
264
+ const selfBridge = safeBridge(self.info);
265
+ if (!selfBridge) throw new Error('域名白名单需要容器在 manyoyo 网络里,这个容器的网络不支持');
266
+ const proxy = await sidecar.ensure();
267
+ allow.push({ ip: proxy.ip, ports: String(proxy.port), proto: 'tcp' });
268
+ antiSpoof.push({ daddr: proxy.ip, saddr: selfBridge.ip });
269
+ const url = `http://${proxy.ip}:${proxy.port}`;
263
270
  managedEnv = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].map(key => `${key}=${url}`);
264
271
  }
265
272
  playwrightHostPorts().forEach(port => addHost(port, 'tcp'));
@@ -285,6 +292,7 @@ function createNetworkManager(options) {
285
292
  }
286
293
  return {
287
294
  managedEnv,
295
+ antiSpoof,
288
296
  dns: dnsServers,
289
297
  allow: uniqueBy(allow, a => `${a.ip}|${a.ports}|${a.proto}`),
290
298
  peerIn,
@@ -296,6 +304,20 @@ function createNetworkManager(options) {
296
304
  try { return bridgeOf(info); } catch (e) { return null; }
297
305
  }
298
306
 
307
+ // 过滤代理的「来源 IP → 容器 id + 策略」:所有运行中、属于本 HOME、策略是 allowlist 的容器
308
+ function syncEgressClients(all) {
309
+ const clients = {};
310
+ for (const item of all) {
311
+ if (!item.running || !state.stateExists(homeDir, item.id)) continue;
312
+ let policy;
313
+ 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 };
317
+ }
318
+ sidecar.writeClients(clients);
319
+ }
320
+
299
321
  async function sendRuleset(name, text) {
300
322
  await run([
301
323
  'run', '--rm', '-i',
@@ -348,6 +370,7 @@ function createNetworkManager(options) {
348
370
  const endpoints = await resolveEndpoints(self, policy, all);
349
371
  state.syncFilesEnv(homeDir, self.id); // 环境变量文件的最新快照(供自启动脚本用)
350
372
  state.writeManagedEnv(homeDir, self.id, endpoints.managedEnv);
373
+ syncEgressClients(all); // 先让代理认得这个容器,再放行门闩
351
374
  await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints));
352
375
  await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
353
376
  const privileged = Boolean(self.info.HostConfig && self.info.HostConfig.Privileged);
@@ -403,7 +426,18 @@ function createNetworkManager(options) {
403
426
  const startedAt = String((info.State && info.State.StartedAt) || '');
404
427
  const sameRun = status && status.startedAt === startedAt;
405
428
  if (sameRun && status.status === 'unsupported') return { status: 'unsupported' };
406
- if (policy && sameRun && status.status === 'applied' && status.policyHash === policyHashOf(policy)) return { status: 'applied' };
429
+ if (policy && sameRun && status.status === 'applied' && status.policyHash === policyHashOf(policy)) {
430
+ // 规则还在,但过滤代理可能意外退了(容器出网此时是失败即关闭):顺手拉起,失败不拦 exec
431
+ if (policy.preset === 'allowlist' && policy.egress.domains.length) {
432
+ try {
433
+ if (!await sidecar.isHealthy()) {
434
+ await sidecar.ensure();
435
+ syncEgressClients(await listManaged());
436
+ }
437
+ } catch (e) { warn(`域名白名单代理恢复失败: ${e.message}`); }
438
+ }
439
+ return { status: 'applied' };
440
+ }
407
441
  return await apply(name, { expectId: id });
408
442
  }
409
443
 
@@ -433,11 +467,49 @@ function createNetworkManager(options) {
433
467
  warn(`容器 ${item.name} 网络规则下发失败: ${e.message}`);
434
468
  }
435
469
  }
470
+ // 没有容器需要重新下发规则时,过滤代理也要在(运行时重启后它同样是停着的)
471
+ await healSidecar().catch(e => warn(`域名白名单代理恢复失败: ${e.message}`));
436
472
  }
437
473
 
438
474
  // 订阅容器 start 事件:容器被外部重启(podman restart、machine 重启后手动 start)后 netns 重建、规则丢失,
439
475
  // 事件到来就重新下发,并重算与它有 peers 关系的容器(它的 IP 可能变了)。
440
- function watchStarts() {
476
+ async function needsSidecar(all) {
477
+ return all.some(item => {
478
+ if (!state.stateExists(homeDir, item.id)) return false;
479
+ try {
480
+ const policy = loadPolicy(item.id);
481
+ return policy.preset === 'allowlist' && policy.egress.domains.length > 0;
482
+ } catch (e) { return false; }
483
+ });
484
+ }
485
+
486
+ // 过滤代理容器自己退出 / 被删:有 allowlist 容器就拉起来,并重写映射
487
+ let lastHealAt = 0;
488
+ let healTimer = null;
489
+ async function healSidecar() {
490
+ // 起不来时(镜像坏了、权限问题)die 事件会反复触发:至少隔 10 秒才再试一次,不能形成忙等循环;
491
+ // 冷却期内的请求合并成冷却结束后的一次
492
+ const wait = 10000 - (Date.now() - lastHealAt);
493
+ if (wait > 0) {
494
+ if (!healTimer) {
495
+ healTimer = setTimeout(() => {
496
+ healTimer = null;
497
+ healSidecar().catch(e => warn(`域名白名单代理恢复失败: ${e.message}`));
498
+ }, wait + 50);
499
+ if (healTimer.unref) healTimer.unref();
500
+ }
501
+ return;
502
+ }
503
+ lastHealAt = Date.now();
504
+ const all = await listManaged();
505
+ if (!await needsSidecar(all)) return;
506
+ await sidecar.ensure();
507
+ syncEgressClients(all);
508
+ }
509
+
510
+ function watchStarts(watchOptions = {}) {
511
+ let attempts = 0;
512
+ let outageWarned = false;
441
513
  let stopped = false;
442
514
  let child = null;
443
515
  let delay = 1000;
@@ -451,7 +523,12 @@ function createNetworkManager(options) {
451
523
  timers.set(key, setTimeout(async () => {
452
524
  timers.delete(key);
453
525
  try {
526
+ if (name === sidecar.name) {
527
+ await healSidecar();
528
+ return;
529
+ }
454
530
  if (status === 'start') await apply(name);
531
+ if (status === 'die') syncEgressClients(await listManaged()); // 回收的 IP 不能再被认成它
455
532
  for (const related of await relatedContainers(name, { includeStopped: status === 'die' })) {
456
533
  await apply(related).catch(e => warn(`容器 ${related} 网络规则重算失败: ${e.message}`));
457
534
  }
@@ -467,7 +544,9 @@ function createNetworkManager(options) {
467
544
  const attrs = event.Attributes || (event.Actor && event.Actor.Attributes) || {};
468
545
  const name = event.Name || attrs.name;
469
546
  const status = event.Status || event.status || event.Action;
470
- if ((status !== 'start' && status !== 'die') || !name || !state.isValidId(attrs['manyoyo.id'])) return null;
547
+ if ((status !== 'start' && status !== 'die') || !name) return null;
548
+ if (name === sidecar.name && attrs['manyoyo.role'] === 'egress') return { name, status };
549
+ if (!state.isValidId(attrs['manyoyo.id'])) return null;
471
550
  return { name, status };
472
551
  };
473
552
 
@@ -499,7 +578,17 @@ function createNetworkManager(options) {
499
578
  };
500
579
  child.on('error', again);
501
580
  child.on('close', again);
502
- reconcileAll().catch(e => warn(`网络规则对账失败: ${e.message}`));
581
+ // 第二次起的连接 = 事件流断过,运行时可能重启过(machine 重启、服务重置):除了补下发规则,
582
+ // 还要让调用方把该拉起的容器拉起来。运行时还没恢复时对账会失败,每次断线只警告一次
583
+ attempts += 1;
584
+ const reconnected = attempts > 1;
585
+ reconcileAll()
586
+ .then(() => (reconnected && watchOptions.onReconnect ? watchOptions.onReconnect() : null))
587
+ .then(() => { outageWarned = false; })
588
+ .catch(e => {
589
+ if (!outageWarned) warn(`网络规则对账失败: ${e.message}`);
590
+ outageWarned = true;
591
+ });
503
592
  };
504
593
  connect();
505
594
  return {
@@ -512,7 +601,7 @@ function createNetworkManager(options) {
512
601
  };
513
602
  }
514
603
 
515
- return { NETWORK_NAME, run, listManaged, loadPolicy, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
604
+ return { NETWORK_NAME, sidecar, run, listManaged, loadPolicy, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
516
605
  }
517
606
 
518
607
  module.exports = {
@@ -26,6 +26,9 @@ function buildContainerRunArgs(options) {
26
26
  'run', '-d',
27
27
  '--name', options.containerName,
28
28
  '--entrypoint', `${SYS_MOUNT}/init.sh`,
29
+ // docker 默认带 NET_RAW(podman 默认不带):有它的容器能发原始包 / 伪造 ARP,冒充别的容器的来源 IP,
30
+ // 域名白名单的过滤代理就是按来源 IP 认容器的,所以统一去掉
31
+ '--cap-drop', 'NET_RAW',
29
32
  ...(options.contModeArgs || []),
30
33
  ...(options.containerExtraArgs || []),
31
34
  ...(options.defaultNetwork && !hasNetworkArg([...(options.contModeArgs || []), ...(options.containerExtraArgs || [])])
@@ -9,17 +9,14 @@ const { parseEnvEntry } = require('./runtime-normalizers');
9
9
 
10
10
  // 每个容器一个状态目录,按随机 manyoyo.id 索引(不按容器名:删了重建同名容器不会继承旧文件)
11
11
  // box/ 读写挂到 /run/manyoyo/ env、autostart.sh、autostart.log(容器可写)
12
- // sys/ 只读挂到 /run/manyoyo-sys/ init.sh、managed.env、net-required
12
+ // sys/ 只读挂到 /run/manyoyo-sys/ init.sh、env.sh、managed.env、net-required
13
13
  // network.json / meta.json 不挂进容器
14
- const ID_RE = /^[0-9a-f]{16}$/;
15
14
  const { ENV_KEY_RE, parseEnvText, serializeEnvEntries, formatEnvValue } = require('./env-text');
16
15
  const MAX_ENV_BYTES = 256 * 1024;
17
16
  const MAX_AUTOSTART_BYTES = 256 * 1024;
18
17
  const INIT_TEMPLATE = path.join(__dirname, 'container-init.sh');
19
-
20
- function isValidId(id) {
21
- return typeof id === 'string' && ID_RE.test(id);
22
- }
18
+ const ENV_TEMPLATE = path.join(__dirname, 'container-env.sh');
19
+ const { ID_RE, isValidId } = require('./container-id');
23
20
 
24
21
  function newId() {
25
22
  return crypto.randomBytes(8).toString('hex');
@@ -44,13 +41,13 @@ function paths(homeDir, id) {
44
41
  autostart: path.join(dir, 'box', 'autostart.sh'),
45
42
  autostartLog: path.join(dir, 'box', 'autostart.log'),
46
43
  init: path.join(dir, 'sys', 'init.sh'),
44
+ envLoader: path.join(dir, 'sys', 'env.sh'),
47
45
  netRequired: path.join(dir, 'sys', 'net-required'),
48
46
  managedEnv: path.join(dir, 'sys', 'managed.env'),
49
47
  filesEnv: path.join(dir, 'sys', 'files.env'),
50
48
  envFiles: path.join(dir, 'env-files.json'),
51
49
  network: path.join(dir, 'network.json'),
52
50
  netStatus: path.join(dir, 'net-status.json'),
53
- egressToken: path.join(dir, 'egress-token'),
54
51
  meta: path.join(dir, 'meta.json')
55
52
  };
56
53
  }
@@ -189,6 +186,7 @@ function createState(options) {
189
186
  function refreshInit(homeDir, id) {
190
187
  const p = paths(homeDir, id);
191
188
  ensureDir(p.sys, 0o700);
189
+ writeFileAtomic(p.envLoader, fs.readFileSync(ENV_TEMPLATE, 'utf-8'), 0o644);
192
190
  writeFileAtomic(p.init, fs.readFileSync(INIT_TEMPLATE, 'utf-8'), 0o755);
193
191
  }
194
192
 
@@ -400,23 +398,6 @@ function writeNetStatus(homeDir, id, status) {
400
398
  writeFileAtomic(paths(homeDir, id).netStatus, `${JSON.stringify(status)}\n`);
401
399
  }
402
400
 
403
- // 过滤代理的容器凭据:256 bit 随机,只存宿主机侧(不挂进容器,容器从自己的 managed.env 拿到带凭据的代理地址)
404
- function readEgressToken(homeDir, id) {
405
- try {
406
- return fs.readFileSync(paths(homeDir, id).egressToken, 'utf-8').trim() || null;
407
- } catch (e) {
408
- return null;
409
- }
410
- }
411
-
412
- function ensureEgressToken(homeDir, id) {
413
- const existing = readEgressToken(homeDir, id);
414
- if (existing) return existing;
415
- const token = crypto.randomBytes(32).toString('hex');
416
- writeFileAtomic(paths(homeDir, id).egressToken, `${token}\n`);
417
- return token;
418
- }
419
-
420
401
  function removeState(homeDir, id) {
421
402
  if (!isValidId(id)) return false;
422
403
  fs.rmSync(stateDir(homeDir, id), { recursive: true, force: true });
@@ -480,8 +461,6 @@ module.exports = {
480
461
  writeNetworkRaw,
481
462
  readNetStatus,
482
463
  writeNetStatus,
483
- readEgressToken,
484
- ensureEgressToken,
485
464
  removeState,
486
465
  listStateIds,
487
466
  findOrphans
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { isValidId } = require('./container-id');
6
+
7
+ // 过滤代理拒绝过的访问记录:每个容器一个 <id>.jsonl,同一 host:port 合并计数。
8
+ // sidecar 里写(目录以读写方式挂入)、宿主机读 / 清空。文件超过 256 KiB 就丢最久没出现的。
9
+ // 只依赖 node 内置模块:sidecar 里带的就是这个文件。
10
+ const MAX_BYTES = 256 * 1024;
11
+ const MAX_RECORDS = 1000;
12
+ const FLUSH_MS = 1000;
13
+ const MAX_PENDING_PER_ID = 500; // 一次合并周期内最多攒这么多不同的 host:port,防止被刷爆内存
14
+ const MAX_HOST_LENGTH = 255;
15
+
16
+ function fileOf(dir, id) {
17
+ if (!isValidId(id)) throw new Error(`非法的容器 id: ${id}`);
18
+ return path.join(dir, `${id}.jsonl`);
19
+ }
20
+
21
+ function parse(text) {
22
+ const records = [];
23
+ String(text || '').split('\n').forEach(line => {
24
+ if (!line.trim()) return;
25
+ try {
26
+ const r = JSON.parse(line);
27
+ if (typeof r.host === 'string' && Number.isInteger(r.port) && Number.isInteger(r.count)) {
28
+ records.push({ host: r.host, port: r.port, reason: String(r.reason || ''), count: r.count, first: String(r.first || ''), last: String(r.last || '') });
29
+ }
30
+ } catch (e) { /* 损坏的行跳过 */ }
31
+ });
32
+ return records;
33
+ }
34
+
35
+ function readFileSafe(file) {
36
+ try {
37
+ const stat = fs.statSync(file);
38
+ if (!stat.isFile() || stat.size > MAX_BYTES * 2) return '';
39
+ return fs.readFileSync(file, 'utf-8');
40
+ } catch (e) {
41
+ return '';
42
+ }
43
+ }
44
+
45
+ /** 宿主机:按最近出现时间倒序返回 [{host, port, reason, count, first, last}] */
46
+ function read(dir, id) {
47
+ return parse(readFileSafe(fileOf(dir, id))).sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0));
48
+ }
49
+
50
+ /** 宿主机:清空(删文件;目录是宿主机用户的,sidecar 以 root 写出的文件也删得掉) */
51
+ function clear(dir, id) {
52
+ try { fs.unlinkSync(fileOf(dir, id)); } catch (e) { if (e.code !== 'ENOENT') throw e; }
53
+ }
54
+
55
+ function serialize(records) {
56
+ const sorted = records.slice().sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0)).slice(0, MAX_RECORDS);
57
+ let text = '';
58
+ const kept = [];
59
+ for (const r of sorted) {
60
+ const line = `${JSON.stringify(r)}\n`;
61
+ if (text.length + line.length > MAX_BYTES) break;
62
+ text += line;
63
+ kept.push(r);
64
+ }
65
+ return text;
66
+ }
67
+
68
+ /**
69
+ * sidecar:记录拒绝。内存里只攒“还没写盘的增量”,每秒合并进文件一次(高频路径不逐条写盘);
70
+ * 合并时总是以磁盘上的当前内容为底,所以宿主机清空(删文件)后会从空白重新计数。
71
+ */
72
+ function createRecorder(options) {
73
+ const dir = options.dir;
74
+ const now = options.now || (() => new Date().toISOString());
75
+ const pending = new Map(); // id → Map(host:port → record 增量)
76
+ let timer = null;
77
+
78
+ function record({ id, host, port, reason }) {
79
+ if (!isValidId(id) || !host || host.length > MAX_HOST_LENGTH || !Number.isInteger(port)) return;
80
+ const byKey = pending.get(id) || new Map();
81
+ pending.set(id, byKey);
82
+ const key = `${host}:${port}`;
83
+ if (!byKey.has(key) && byKey.size >= MAX_PENDING_PER_ID) return;
84
+ const at = now();
85
+ const hit = byKey.get(key);
86
+ if (hit) {
87
+ hit.count += 1;
88
+ hit.last = at;
89
+ hit.reason = reason;
90
+ } else {
91
+ byKey.set(key, { host, port, reason, count: 1, first: at, last: at });
92
+ }
93
+ if (!timer) {
94
+ timer = setTimeout(() => { timer = null; flush(); }, FLUSH_MS);
95
+ if (timer.unref) timer.unref();
96
+ }
97
+ }
98
+
99
+ function flush() {
100
+ const batch = [...pending.entries()];
101
+ pending.clear();
102
+ for (const [id, byKey] of batch) {
103
+ try {
104
+ const file = fileOf(dir, id);
105
+ const merged = new Map(parse(readFileSafe(file)).map(r => [`${r.host}:${r.port}`, r]));
106
+ byKey.forEach((delta, key) => {
107
+ const old = merged.get(key);
108
+ merged.set(key, old
109
+ ? { ...old, reason: delta.reason, count: old.count + delta.count, last: delta.last }
110
+ : delta);
111
+ });
112
+ const tmp = `${file}.tmp`;
113
+ fs.writeFileSync(tmp, serialize([...merged.values()]), { mode: 0o644 });
114
+ fs.renameSync(tmp, file);
115
+ } catch (e) { /* 写不进去不影响代理本身 */ }
116
+ }
117
+ }
118
+
119
+ function stop() {
120
+ if (timer) clearTimeout(timer);
121
+ timer = null;
122
+ flush();
123
+ }
124
+
125
+ return { record, flush, stop };
126
+ }
127
+
128
+ module.exports = { MAX_BYTES, MAX_RECORDS, MAX_PENDING_PER_ID, read, clear, parse, serialize, createRecorder };
@@ -1,17 +1,16 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('crypto');
4
3
  const dns = require('dns').promises;
5
4
  const http = require('http');
6
5
  const net = require('net');
7
6
  const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
8
- const { isValidId } = require('./container-state');
9
7
 
10
- // 域名白名单的强制执行点:serve 进程内的 HTTP(S) 过滤代理。
11
- // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按域名放行 CONNECT / 普通 HTTP,
8
+ // 域名白名单的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
9
+ // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按连接的来源 IP 认出是哪个容器(getClient),
10
+ // 认不出的来源一律 403;按域名放行 CONNECT / 普通 HTTP,
12
11
  // 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
13
- // 除非该容器的 egress.rules 显式放行该 IP:端口。监听 0.0.0.0(容器只能经宿主机 LAN IP 访问宿主机),靠凭据挡局域网。
14
- const REALM = 'manyoyo';
12
+ // 除非该容器的 egress.rules 显式放行该 IP:端口。
13
+ // 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
15
14
  const CONNECT_TIMEOUT_MS = 15000;
16
15
  const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
17
16
  const MAX_TUNNELS_PER_CONTAINER = 256;
@@ -65,21 +64,6 @@ function ruleCovers(rule, ip, port) {
65
64
  return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
66
65
  }
67
66
 
68
- function parseBasicAuth(header) {
69
- const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(String(header || '').trim());
70
- if (!m) return null;
71
- const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
72
- const i = decoded.indexOf(':');
73
- if (i < 0) return null;
74
- return { id: decoded.slice(0, i), token: decoded.slice(i + 1) };
75
- }
76
-
77
- function tokenEquals(a, b) {
78
- const x = Buffer.from(String(a));
79
- const y = Buffer.from(String(b));
80
- return x.length === y.length && crypto.timingSafeEqual(x, y);
81
- }
82
-
83
67
  function parseTarget(text) {
84
68
  const m = /^(?:\[([0-9a-fA-F:.]+)\]|([^:/\s]+)):(\d{1,5})$/.exec(text);
85
69
  if (!m) return null;
@@ -90,40 +74,53 @@ function parseTarget(text) {
90
74
 
91
75
  /**
92
76
  * @param {object} options
93
- * @param {(id: string) => string|null} options.getToken 容器的代理 token(没有返回 null)
94
- * @param {(id: string) => {preset: string, domains: string[], rules: object[]}|null} options.getPolicy
77
+ * @param {(ip: string) => {id: string, preset: string, domains: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
78
+ * @param {(event: {id: string, host: string, port: number, reason: string}) => void} [options.onDenied] 拒绝记录
95
79
  * @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
96
80
  * @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
97
81
  */
98
82
  function createEgressProxy(options) {
99
- const getToken = options.getToken;
100
- const getPolicy = options.getPolicy;
83
+ const getClient = options.getClient;
84
+ const onDenied = options.onDenied || (() => {});
101
85
  const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
102
- let upstream = null;
103
- if (options.upstream) {
104
- try {
105
- const url = new URL(options.upstream);
106
- if (url.protocol === 'http:') {
107
- upstream = { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80, auth: url.username ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' };
86
+ // upstream 可以是字符串或返回字符串的函数(sidecar 里按文件改动热更新);解析结果按字符串缓存
87
+ let parsedFor = null;
88
+ let parsedUpstream = null;
89
+ function currentUpstream() {
90
+ const text = typeof options.upstream === 'function' ? options.upstream() : options.upstream;
91
+ if (text !== parsedFor) {
92
+ parsedFor = text;
93
+ parsedUpstream = null;
94
+ if (text) {
95
+ try {
96
+ const url = new URL(text);
97
+ if (url.protocol === 'http:') {
98
+ parsedUpstream = { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80, auth: url.username ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' };
99
+ }
100
+ } catch (e) { /* 无效上游当作没有 */ }
108
101
  }
109
- } catch (e) { /* 无效上游当作没有 */ }
102
+ }
103
+ return parsedUpstream;
110
104
  }
111
105
  const tunnels = new Map();
112
106
  const sockets = new Set(); // CONNECT 劫持出来的 socket 不在 http.Server 的跟踪里,stop 时要自己关
113
107
  let server = null;
114
108
 
115
- function authenticate(req) {
116
- const cred = parseBasicAuth(req.headers['proxy-authorization']);
117
- if (!cred || !isValidId(cred.id)) return null;
118
- const token = getToken(cred.id);
119
- if (!token || !tokenEquals(token, cred.token)) return null;
120
- return cred.id;
109
+ function normalizeSource(address) {
110
+ const text = String(address || '');
111
+ return /^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(text) ? text.slice(7) : text;
112
+ }
113
+
114
+ // 按来源 IP 认容器:返回 {id, policy},认不出返回 null
115
+ function identify(socket) {
116
+ let client = null;
117
+ try { client = getClient(normalizeSource(socket.remoteAddress)); } catch (e) { client = null; }
118
+ return client && client.id ? { id: client.id, policy: client } : null;
121
119
  }
122
120
 
123
121
  // 返回 {ok:true, ip?} 或 {ok:false, status, message}
124
- async function authorize(id, host, port) {
125
- const policy = getPolicy(id);
126
- if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'policy' };
122
+ async function authorize(policy, host, port, upstream) {
123
+ if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'domain' };
127
124
  const isIp = net.isIP(host) !== 0;
128
125
  const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
129
126
  if (isIp) {
@@ -153,11 +150,21 @@ function createEgressProxy(options) {
153
150
  return { ok: true, ip: usable[0] };
154
151
  }
155
152
 
153
+ // Proxy-Status(RFC 9209)只带通用错误类型;拒绝一律 403,正文只有 Forbidden
154
+ function statusHeader(message) {
155
+ return message === 'address' ? 'proxy; error=destination_ip_prohibited' : 'proxy; error=http_request_denied';
156
+ }
157
+
156
158
  function reject(res, status, message) {
157
- const headers = { 'Content-Type': 'text/plain; charset=utf-8', Connection: 'close' };
158
- if (status === 407) headers['Proxy-Authenticate'] = `Basic realm="${REALM}"`;
159
+ const body = { 400: 'Bad Request', 403: 'Forbidden', 429: 'Too Many Requests' }[status] || 'Bad Gateway';
160
+ const headers = { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body), Connection: 'close' };
161
+ if (status === 403) headers['Proxy-Status'] = statusHeader(message);
159
162
  res.writeHead(status, headers);
160
- res.end(`manyoyo egress proxy: ${message}\n`);
163
+ res.end(body);
164
+ }
165
+
166
+ function deny(client, host, port, reason) {
167
+ try { onDenied({ id: client ? client.id : '', host, port, reason }); } catch (e) { /* 记录失败不影响拒绝 */ }
161
168
  }
162
169
 
163
170
  function acquire(id) {
@@ -172,8 +179,8 @@ function createEgressProxy(options) {
172
179
  if (n <= 0) tunnels.delete(id); else tunnels.set(id, n);
173
180
  }
174
181
 
175
- function rawReject(socket, status, text) {
176
- const extra = status === 407 ? `Proxy-Authenticate: Basic realm="${REALM}"\r\n` : '';
182
+ function rawReject(socket, status, text, message) {
183
+ const extra = status === 403 ? `Proxy-Status: ${statusHeader(message)}\r\n` : '';
177
184
  socket.end(`HTTP/1.1 ${status} ${text}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
178
185
  }
179
186
 
@@ -186,12 +193,17 @@ function createEgressProxy(options) {
186
193
  async function onConnect(req, clientSocket, head) {
187
194
  track(clientSocket);
188
195
  clientSocket.on('error', () => {});
189
- const id = authenticate(req);
190
- if (!id) return rawReject(clientSocket, 407, 'Proxy Authentication Required');
196
+ const client = identify(clientSocket);
191
197
  const target = parseTarget(String(req.url || ''));
198
+ if (!client) return rawReject(clientSocket, 403, 'Forbidden', 'domain');
192
199
  if (!target) return rawReject(clientSocket, 400, 'Bad Request');
193
- const verdict = await authorize(id, target.host, target.port);
194
- if (!verdict.ok) return rawReject(clientSocket, verdict.status, verdict.status === 403 ? 'Forbidden' : 'Bad Gateway');
200
+ const id = client.id;
201
+ const upstream = currentUpstream();
202
+ const verdict = await authorize(client.policy, target.host, target.port, upstream);
203
+ if (!verdict.ok) {
204
+ if (verdict.status === 403) deny(client, target.host, target.port, verdict.message);
205
+ return rawReject(clientSocket, verdict.status, verdict.status === 403 ? 'Forbidden' : 'Bad Gateway', verdict.message);
206
+ }
195
207
  if (!acquire(id)) return rawReject(clientSocket, 429, 'Too Many Requests');
196
208
  let released = false;
197
209
  const done = () => { if (!released) { released = true; release(id); } };
@@ -239,17 +251,22 @@ function createEgressProxy(options) {
239
251
 
240
252
  // 普通 HTTP 代理(绝对 URI 请求)
241
253
  async function onRequest(req, res) {
242
- const id = authenticate(req);
243
- if (!id) return reject(res, 407, 'proxy authentication required');
254
+ const client = identify(req.socket);
255
+ if (!client) return reject(res, 403, 'domain');
256
+ const id = client.id;
244
257
  let url;
245
- try { url = new URL(req.url); } catch (e) { return reject(res, 400, 'absolute URI required'); }
246
- if (url.protocol !== 'http:') return reject(res, 400, 'only http: URIs');
258
+ try { url = new URL(req.url); } catch (e) { return reject(res, 400, 'bad request'); }
259
+ if (url.protocol !== 'http:') return reject(res, 400, 'bad request');
247
260
  const port = Number(url.port) || 80;
248
261
  const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
249
- const verdict = await authorize(id, host, port);
250
- if (!verdict.ok) return reject(res, verdict.status, verdict.status === 403 ? 'forbidden by policy' : 'bad gateway');
251
- if (!acquire(id)) return reject(res, 429, 'too many connections');
252
- const headers = { ...req.headers };
262
+ const upstream = currentUpstream();
263
+ const verdict = await authorize(client.policy, host, port, upstream);
264
+ if (!verdict.ok) {
265
+ if (verdict.status === 403) deny(client, host, port, verdict.message);
266
+ return reject(res, verdict.status, verdict.message);
267
+ }
268
+ if (!acquire(id)) return reject(res, 429, 'limit');
269
+ const headers = { ...req.headers, host: url.host }; // 放行判断看的是 URL 里的主机,Host 头必须与它一致(RFC 9112)
253
270
  delete headers['proxy-authorization'];
254
271
  delete headers['proxy-connection'];
255
272
  const outbound = upstream
@@ -263,14 +280,14 @@ function createEgressProxy(options) {
263
280
  proxyRes.pipe(res);
264
281
  });
265
282
  proxyReq.on('timeout', () => proxyReq.destroy());
266
- proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, 'bad gateway'); else res.destroy(); });
283
+ proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, 'upstream'); else res.destroy(); });
267
284
  res.on('close', () => { done(); proxyReq.destroy(); });
268
285
  req.pipe(proxyReq);
269
286
  }
270
287
 
271
288
  function start(listen) {
272
289
  return new Promise((resolve, fail) => {
273
- server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, 'error'); } catch (e) { /* 连接已断 */ } }); });
290
+ server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, 'upstream'); } catch (e) { /* 连接已断 */ } }); });
274
291
  server.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });
275
292
  server.headersTimeout = 20000;
276
293
  server.on('clientError', (err, socket) => socket.destroy());
@@ -295,4 +312,4 @@ function createEgressProxy(options) {
295
312
  return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
296
313
  }
297
314
 
298
- module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseBasicAuth, parseTarget, REALM };
315
+ module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseTarget };