@xcanwin/manyoyo 8.4.0 → 8.5.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
@@ -9,6 +9,7 @@ const net = require('net');
9
9
  const readline = require('readline');
10
10
  const { Command, Help } = require('commander');
11
11
  const { startWebServer } = require('../lib/web/server');
12
+ const { isSidecarName } = require('../lib/egress-sidecar');
12
13
  const { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile, hasNetworkArg } = require('../lib/container-run');
13
14
  const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
14
15
  const { resolveUpdateImageVersion } = require('../lib/image-version-policy');
@@ -994,6 +995,7 @@ function getContList() {
994
995
  const image = cols[4] || '';
995
996
  // include manyoyo runtime containers (image match)
996
997
  // and plugin containers (both legacy manyoyo-* and new my-* prefixes)
998
+ if (isSidecarName(name)) return false; // 域名白名单的过滤代理容器不是用户的会话容器
997
999
  return image.includes('manyoyo') || name.startsWith('manyoyo-') || name.startsWith('my-');
998
1000
  });
999
1001
 
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const { domainAllowed } = require('./network-policy');
4
+
5
+ // 容器里的 Chrome 启动后会自己连一些谷歌的后台服务(更新、账号同步、字体 / 资源)。白名单下它们必然被拦,
6
+ // 对用户没有意义:网络分区的“最近被拦截”把它们折叠成“浏览器后台请求”,对话里也不为它们提示。
7
+ // 数据只写在这一处。
8
+ const BROWSER_BACKGROUND_DOMAINS = [
9
+ 'accounts.google.com',
10
+ 'update.googleapis.com',
11
+ 'www.google.com',
12
+ 'www.gstatic.com',
13
+ 'dl.google.com',
14
+ 'clients2.google.com',
15
+ 'android.clients.google.com',
16
+ 'content-autofill.googleapis.com'
17
+ ];
18
+
19
+ function isBrowserBackground(host) {
20
+ return domainAllowed(BROWSER_BACKGROUND_DOMAINS, host);
21
+ }
22
+
23
+ module.exports = { BROWSER_BACKGROUND_DOMAINS, isBrowserBackground };
@@ -0,0 +1,34 @@
1
+ # manyoyo 容器内的 env 加载:init.sh 启动自启动前加载,终端里的 `reload-env` 也用它。
2
+ # 由宿主机以只读目录挂到 /run/manyoyo-sys/env.sh,容器内不可改。
3
+ # 与宿主机侧(lib/env-text.js)同一语法:一行 KEY=VALUE,# 注释与空行忽略,可选 `export ` 前缀与 `=` 两侧空白,
4
+ # 值两端成对的引号去掉;不展开变量、不执行命令;key 必须合法
5
+ load_env() {
6
+ local file=$1 line key val
7
+ [ -f "$file" ] || return 0
8
+ while IFS= read -r line || [ -n "$line" ]; do
9
+ line=${line%$'\r'}
10
+ line="${line#"${line%%[![:space:]]*}"}"
11
+ line="${line%"${line##*[![:space:]]}"}"
12
+ case $line in ''|'#'*) continue ;; esac
13
+ if [[ $line =~ ^export[[:space:]]+ ]]; then line=${line#"${BASH_REMATCH[0]}"}; fi
14
+ [[ $line =~ ^([^=[:space:]]+)[[:space:]]*=[[:space:]]*(.*)$ ]] || continue
15
+ key=${BASH_REMATCH[1]}
16
+ val=${BASH_REMATCH[2]}
17
+ [[ $key =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
18
+ if [ "${#val}" -ge 2 ]; then
19
+ case $val in
20
+ \"*\") val=${val:1:${#val}-2} ;;
21
+ \'*\') val=${val:1:${#val}-2} ;;
22
+ esac
23
+ fi
24
+ export "$key=$val"
25
+ done < "$file"
26
+ }
27
+
28
+ # 已打开的终端里让最新的环境变量生效(优先级与新开终端一致:managed < 环境变量文件 < 用户 env)
29
+ reload-env() {
30
+ local sys=${MANYOYO_SYS_DIR:-/run/manyoyo-sys} box=${MANYOYO_BOX_DIR:-/run/manyoyo}
31
+ load_env "$sys/managed.env"
32
+ load_env "$sys/files.env"
33
+ load_env "$box/env"
34
+ }
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ // 容器 id:随机 16 位十六进制,状态目录、过滤代理的映射与拒绝记录都按它索引。
4
+ // 单独成文件:过滤代理 sidecar 里只带得动这几个无依赖的小文件。
5
+ const ID_RE = /^[0-9a-f]{16}$/;
6
+
7
+ function isValidId(id) {
8
+ return typeof id === 'string' && ID_RE.test(id);
9
+ }
10
+
11
+ module.exports = { ID_RE, isValidId };
@@ -10,30 +10,7 @@ log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG" 2>/dev/null; }
10
10
 
11
11
  trap 'kill -TERM -1 2>/dev/null; exit 0' TERM INT
12
12
 
13
- # 与宿主机侧(lib/env-text.js)同一语法:一行 KEY=VALUE,# 注释与空行忽略,可选 `export ` 前缀与 `=` 两侧空白,
14
- # 值两端成对的引号去掉;不展开变量、不执行命令;key 必须合法
15
- load_env() {
16
- local file=$1 line key val
17
- [ -f "$file" ] || return 0
18
- while IFS= read -r line || [ -n "$line" ]; do
19
- line=${line%$'\r'}
20
- line="${line#"${line%%[![:space:]]*}"}"
21
- line="${line%"${line##*[![:space:]]}"}"
22
- case $line in ''|'#'*) continue ;; esac
23
- if [[ $line =~ ^export[[:space:]]+ ]]; then line=${line#"${BASH_REMATCH[0]}"}; fi
24
- [[ $line =~ ^([^=[:space:]]+)[[:space:]]*=[[:space:]]*(.*)$ ]] || continue
25
- key=${BASH_REMATCH[1]}
26
- val=${BASH_REMATCH[2]}
27
- [[ $key =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
28
- if [ "${#val}" -ge 2 ]; then
29
- case $val in
30
- \"*\") val=${val:1:${#val}-2} ;;
31
- \'*\') val=${val:1:${#val}-2} ;;
32
- esac
33
- fi
34
- export "$key=$val"
35
- done < "$file"
36
- }
13
+ . "$SYS/env.sh"
37
14
 
38
15
  # 日志超过 256 KiB 时只保留尾部 128 KiB
39
16
  if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 262144 ]; then
@@ -44,8 +21,12 @@ run_autostart() {
44
21
  if [ -e "$SYS/net-required" ]; then
45
22
  log "等待 manyoyo 下发网络规则"
46
23
  while [ ! -e "$GATE/ready" ]; do sleep 0.5; done
24
+ log "网络规则已就绪"
25
+ fi
26
+ if [ ! -s "$BOX/autostart.sh" ]; then
27
+ log "没有自启动命令"
28
+ exit 0
47
29
  fi
48
- [ -s "$BOX/autostart.sh" ] || exit 0
49
30
  load_env "$SYS/managed.env"
50
31
  load_env "$SYS/files.env"
51
32
  load_env "$BOX/env"
@@ -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 };