@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 +2 -0
- package/lib/browser-background.js +23 -0
- package/lib/container-env.sh +34 -0
- package/lib/container-id.js +11 -0
- package/lib/container-init.sh +6 -25
- package/lib/container-network.js +101 -12
- package/lib/container-run.js +3 -0
- package/lib/container-state.js +5 -26
- package/lib/egress-denied.js +128 -0
- package/lib/egress-proxy.js +78 -61
- package/lib/egress-sidecar-main.js +112 -0
- package/lib/egress-sidecar.js +276 -0
- package/lib/json5-text-edit.js +5 -5
- package/lib/network-policy.js +5 -1
- package/lib/port-forward.js +5 -3
- package/lib/web/AGENTS.md +1 -1
- package/lib/web/container-manage.js +56 -0
- package/lib/web/denied-notify.js +52 -0
- package/lib/web/index.html +44 -44
- package/lib/web/server.js +100 -110
- package/package.json +1 -1
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 };
|
package/lib/container-init.sh
CHANGED
|
@@ -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
|
-
|
|
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"
|
package/lib/container-network.js
CHANGED
|
@@ -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
|
|
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
|
-
// 过滤代理(
|
|
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
|
|
259
|
-
if (!
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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))
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = {
|
package/lib/container-run.js
CHANGED
|
@@ -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 || [])])
|
package/lib/container-state.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|