@xcanwin/manyoyo 8.3.2 → 8.4.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/README.md +1 -1
- package/bin/manyoyo.js +109 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/container-exec.js +132 -0
- package/lib/container-init.sh +59 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +526 -0
- package/lib/container-run.js +24 -3
- package/lib/container-state.js +488 -0
- package/lib/egress-proxy.js +298 -0
- package/lib/env-text.js +62 -0
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +265 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +135 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +450 -0
- package/lib/web/index.html +80 -76
- package/lib/web/server.js +303 -76
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# manyoyo 容器 PID 1:回收僵尸、响应 SIGTERM、在(网络规则就绪后)运行自启动脚本。
|
|
3
|
+
# 由宿主机以只读目录挂到 /run/manyoyo-sys/,容器内不可改。
|
|
4
|
+
BOX=/run/manyoyo
|
|
5
|
+
SYS=/run/manyoyo-sys
|
|
6
|
+
GATE=/run/manyoyo-gate
|
|
7
|
+
LOG="$BOX/autostart.log"
|
|
8
|
+
|
|
9
|
+
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG" 2>/dev/null; }
|
|
10
|
+
|
|
11
|
+
trap 'kill -TERM -1 2>/dev/null; exit 0' TERM INT
|
|
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
|
+
}
|
|
37
|
+
|
|
38
|
+
# 日志超过 256 KiB 时只保留尾部 128 KiB
|
|
39
|
+
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 262144 ]; then
|
|
40
|
+
tail -c 131072 "$LOG" > "$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
run_autostart() {
|
|
44
|
+
if [ -e "$SYS/net-required" ]; then
|
|
45
|
+
log "等待 manyoyo 下发网络规则"
|
|
46
|
+
while [ ! -e "$GATE/ready" ]; do sleep 0.5; done
|
|
47
|
+
fi
|
|
48
|
+
[ -s "$BOX/autostart.sh" ] || exit 0
|
|
49
|
+
load_env "$SYS/managed.env"
|
|
50
|
+
load_env "$SYS/files.env"
|
|
51
|
+
load_env "$BOX/env"
|
|
52
|
+
log "开始运行自启动脚本"
|
|
53
|
+
/bin/bash "$BOX/autostart.sh" >> "$LOG" 2>&1
|
|
54
|
+
log "自启动脚本退出,退出码 $?"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setsid bash -c "$(declare -f log load_env run_autostart); BOX=$BOX SYS=$SYS GATE=$GATE LOG=$LOG; run_autostart" >/dev/null 2>&1 &
|
|
58
|
+
|
|
59
|
+
while :; do sleep 3600 & wait $!; done
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizePolicy, defaultPolicy } = require('./network-policy');
|
|
4
|
+
|
|
5
|
+
const MAX_AUTOSTART_BYTES = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function firstDefined(...values) {
|
|
12
|
+
return values.find(value => value !== undefined && value !== null);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 新建容器时的自启动与网络:命令行 / Web 请求 > runs.<name> > 全局配置,标量与对象都是整体覆盖。
|
|
17
|
+
* 字段:`autostart`(脚本文本)、`autostartOnServe`(serve 启动时自动拉起)、`network`(策略,见 lib/network-policy.js)。
|
|
18
|
+
* @returns {{autostart: string, network: object, fromRequest: boolean}} network 已校验,autostartOnServe 并进 network
|
|
19
|
+
*/
|
|
20
|
+
function resolveManageOptions({ requestOptions = {}, runConfig = {}, globalConfig = {} } = {}) {
|
|
21
|
+
const layers = [requestOptions, runConfig, globalConfig];
|
|
22
|
+
const autostart = firstDefined(...layers.map(l => l.autostart));
|
|
23
|
+
if (autostart !== undefined && typeof autostart !== 'string') throw new Error('autostart 必须是字符串(脚本文本)');
|
|
24
|
+
if (typeof autostart === 'string' && Buffer.byteLength(autostart) > MAX_AUTOSTART_BYTES) throw new Error('autostart 脚本过大');
|
|
25
|
+
const network = firstDefined(...layers.map(l => l.network));
|
|
26
|
+
if (network !== undefined && !isPlainObject(network)) throw new Error('network 必须是对象');
|
|
27
|
+
const onServe = firstDefined(...layers.map(l => l.autostartOnServe));
|
|
28
|
+
const policy = normalizePolicy({
|
|
29
|
+
...(network || defaultPolicy()),
|
|
30
|
+
autostartOnServe: onServe === undefined ? (network && network.autostartOnServe) : onServe === true
|
|
31
|
+
});
|
|
32
|
+
return { autostart: autostart || '', network: policy, fromRequest: requestOptions.network !== undefined };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { resolveManageOptions, MAX_AUTOSTART_BYTES };
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const dns = require('dns').promises;
|
|
5
|
+
const JSON5 = require('json5');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const net = require('net');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const state = require('./container-state');
|
|
12
|
+
const { buildExecArgs } = require('./container-exec');
|
|
13
|
+
const policyLib = require('./network-policy');
|
|
14
|
+
|
|
15
|
+
// 容器网络:把策略翻译成 nft 规则,用一次性 helper 容器(--network container:<名> + NET_ADMIN)写进
|
|
16
|
+
// 目标容器自己的 netns。Agent 没有 NET_ADMIN,改不掉;规则热更新,不重启。
|
|
17
|
+
// 容器重启后 netns 重建、规则丢失 → 每次启动后重新下发,/run/manyoyo-gate/ready 门闩(tmpfs,重启即清)
|
|
18
|
+
// 标记“本次启动已下发”,没有门闩时 init 不跑自启动脚本、manyoyo 也不往里 exec(失败即关闭)。
|
|
19
|
+
|
|
20
|
+
const NETWORK_NAME = 'manyoyo';
|
|
21
|
+
const GATE_READY = '/run/manyoyo-gate/ready';
|
|
22
|
+
const PROXY_ENV_KEYS = new Set(['http_proxy', 'https_proxy', 'all_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY']);
|
|
23
|
+
const HOST_NAMES = ['host.containers.internal', 'host.docker.internal'];
|
|
24
|
+
|
|
25
|
+
function envMapOf(info) {
|
|
26
|
+
const map = {};
|
|
27
|
+
((info.Config && info.Config.Env) || []).forEach(line => {
|
|
28
|
+
const i = String(line).indexOf('=');
|
|
29
|
+
if (i > 0) map[line.slice(0, i)] = line.slice(i + 1);
|
|
30
|
+
});
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function labelsOf(info) {
|
|
35
|
+
return (info.Config && info.Config.Labels) || {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ipv4ToInt(ip) {
|
|
39
|
+
return ip.split('.').reduce((acc, part) => ((acc << 8) | Number(part)) >>> 0, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function intToIpv4(value) {
|
|
43
|
+
return [24, 16, 8, 0].map(shift => (value >>> shift) & 255).join('.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function subnetOf(ip, prefixLen) {
|
|
47
|
+
if (!net.isIPv4(ip) || !Number.isInteger(prefixLen) || prefixLen < 8 || prefixLen > 30) return '';
|
|
48
|
+
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0;
|
|
49
|
+
return `${intToIpv4((ipv4ToInt(ip) & mask) >>> 0)}/${prefixLen}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 容器当前所在 bridge 的 IP / 网关 / 网段;slirp4netns 等没有 bridge 时返回 null。 */
|
|
53
|
+
function bridgeOf(info) {
|
|
54
|
+
const networks = (info.NetworkSettings && info.NetworkSettings.Networks) || {};
|
|
55
|
+
const pick = networks[NETWORK_NAME] || Object.values(networks).find(n => n && n.IPAddress);
|
|
56
|
+
if (!pick || !pick.IPAddress) return null;
|
|
57
|
+
const subnet = subnetOf(pick.IPAddress, Number(pick.IPPrefixLen));
|
|
58
|
+
if (!subnet || !pick.Gateway) {
|
|
59
|
+
throw new Error('无法确定容器网络的网段,不下发规则');
|
|
60
|
+
}
|
|
61
|
+
return { ip: pick.IPAddress, gateway: pick.Gateway, subnet };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseHostsAndResolv(text) {
|
|
65
|
+
const hostIps = [];
|
|
66
|
+
const dnsServers = [];
|
|
67
|
+
String(text || '').split('\n').forEach(raw => {
|
|
68
|
+
const line = raw.trim();
|
|
69
|
+
if (!line || line.startsWith('#')) return;
|
|
70
|
+
const parts = line.split(/\s+/);
|
|
71
|
+
if (parts[0] === 'nameserver' && net.isIP(parts[1])) {
|
|
72
|
+
if (!/^127\./.test(parts[1]) && parts[1] !== '::1') dnsServers.push(parts[1]);
|
|
73
|
+
} else if (net.isIP(parts[0]) && parts.slice(1).some(name => HOST_NAMES.includes(name))) {
|
|
74
|
+
if (!hostIps.includes(parts[0])) hostIps.push(parts[0]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return { hostIps, dnsServers };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveHostToIps(host) {
|
|
81
|
+
if (net.isIP(host)) return [host];
|
|
82
|
+
try {
|
|
83
|
+
const records = await dns.lookup(host, { all: true });
|
|
84
|
+
return records.map(r => r.address);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseUrlHostPort(value) {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
|
|
93
|
+
const defaultPort = { 'http:': 80, 'https:': 443, 'socks5:': 1080, 'socks5h:': 1080 }[url.protocol] || 80;
|
|
94
|
+
return { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || defaultPort };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function uniqueBy(items, keyFn) {
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
return items.filter(item => {
|
|
103
|
+
const key = keyFn(item);
|
|
104
|
+
if (seen.has(key)) return false;
|
|
105
|
+
seen.add(key);
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createNetworkManager(options) {
|
|
111
|
+
const homeDir = options.homeDir || os.homedir();
|
|
112
|
+
const command = options.command;
|
|
113
|
+
const runtimeEnv = options.env ? { ...process.env, ...options.env } : process.env;
|
|
114
|
+
const getImageRef = typeof options.imageRef === 'function' ? options.imageRef : () => options.imageRef;
|
|
115
|
+
const getMirrorUrls = options.getMirrorUrls || (() => []);
|
|
116
|
+
const getFilterProxy = options.getFilterProxy || (() => null);
|
|
117
|
+
const warn = options.warn || (() => {});
|
|
118
|
+
const locks = new Map();
|
|
119
|
+
|
|
120
|
+
function run(args, { input, timeout = 30000 } = {}) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
let child;
|
|
123
|
+
try {
|
|
124
|
+
child = spawn(command, args, { env: runtimeEnv, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
reject(e);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
let stdout = '';
|
|
130
|
+
let stderr = '';
|
|
131
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
|
132
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
133
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
134
|
+
child.on('error', error => { clearTimeout(timer); reject(error); });
|
|
135
|
+
child.on('close', code => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
resolve(stdout);
|
|
139
|
+
} else {
|
|
140
|
+
const error = new Error((stderr || stdout || `exit ${code}`).trim().split('\n').slice(-3).join(' '));
|
|
141
|
+
error.status = code;
|
|
142
|
+
reject(error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
child.stdin.on('error', () => {});
|
|
146
|
+
child.stdin.end(input || '');
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// buildExecArgs 只需要同步 inspect 取 id;这里 withEnv:false 不会用到
|
|
151
|
+
const execCtx = { homeDir, dockerExecArgs: () => '' };
|
|
152
|
+
|
|
153
|
+
async function execInContainer(name, cmd) {
|
|
154
|
+
const built = buildExecArgs(execCtx, name, { withEnv: false, command: cmd });
|
|
155
|
+
try {
|
|
156
|
+
return await run(built.args, { timeout: 15000 });
|
|
157
|
+
} finally {
|
|
158
|
+
built.cleanup();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function inspectMany(refs) {
|
|
163
|
+
if (!refs.length) return [];
|
|
164
|
+
const out = await run(['inspect', '--format', '{{json .}}', ...refs]);
|
|
165
|
+
return out.split('\n').map(line => line.trim()).filter(Boolean).map(line => JSON.parse(line));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function listManaged() {
|
|
169
|
+
const ids = (await run(['ps', '-a', '-q', '--no-trunc', '--filter', 'label=manyoyo.id'])).split('\n').map(s => s.trim()).filter(Boolean);
|
|
170
|
+
const infos = await inspectMany(ids);
|
|
171
|
+
return infos.map(info => ({
|
|
172
|
+
info,
|
|
173
|
+
id: labelsOf(info)['manyoyo.id'],
|
|
174
|
+
name: String(info.Name || '').replace(/^\//, ''),
|
|
175
|
+
running: Boolean(info.State && (info.State.Running === true || info.State.Status === 'running'))
|
|
176
|
+
})).filter(item => state.isValidId(item.id));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function loadPolicy(id) {
|
|
180
|
+
const raw = state.readNetworkRaw(homeDir, id);
|
|
181
|
+
return policyLib.normalizePolicy(raw || undefined);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function ensureBridgeNetwork() {
|
|
185
|
+
try {
|
|
186
|
+
await run(['network', 'inspect', NETWORK_NAME]);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
await run(['network', 'create', NETWORK_NAME]);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Playwright 浏览器服务在宿主机上的端口:切模式(up headed / vnc / chrome)时容器不重建,
|
|
193
|
+
// 所以不能只看当前 config.json——配置里的端口(默认 8935)一律放行,当前 config.json 里出现的也放行
|
|
194
|
+
function playwrightHostPorts() {
|
|
195
|
+
const ports = new Set([8935]);
|
|
196
|
+
try {
|
|
197
|
+
const text = fs.readFileSync(path.join(homeDir, '.manyoyo', 'plugin', 'playwright', 'current', 'config.json'), 'utf-8');
|
|
198
|
+
const re = /(?:host\.containers\.internal|host\.docker\.internal):(\d{2,5})/g;
|
|
199
|
+
let m;
|
|
200
|
+
while ((m = re.exec(text))) ports.add(Number(m[1]));
|
|
201
|
+
} catch (e) { /* 没有就只用默认 */ }
|
|
202
|
+
try {
|
|
203
|
+
const config = JSON5.parse(fs.readFileSync(path.join(homeDir, '.manyoyo', 'manyoyo.json'), 'utf-8'));
|
|
204
|
+
const port = Number(config && config.plugins && config.plugins.playwright && config.plugins.playwright.port);
|
|
205
|
+
if (Number.isInteger(port)) ports.add(port);
|
|
206
|
+
} catch (e) { /* 没有配置文件 */ }
|
|
207
|
+
return [...ports].filter(p => p >= 1 && p <= 65535);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 容器里的 /etc/hosts、/etc/resolv.conf 是容器可写的,不能拿来生成防火墙规则(容器写一个 `nameserver <公网 IP>`
|
|
211
|
+
// 就能给自己放行一条隧道)。改为在同一个网络里起一个一次性的全新容器读取运行时生成的值:
|
|
212
|
+
// 复制创建时的 --network / --dns / --add-host(都是创建者给定的,不是容器内进程能改的)。
|
|
213
|
+
async function readRuntimeNetworkFacts(info) {
|
|
214
|
+
const args = ['run', '--rm', '--pull=never', '--user', 'root', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges'];
|
|
215
|
+
const bridge = safeBridge(info);
|
|
216
|
+
if (bridge) {
|
|
217
|
+
const networks = (info.NetworkSettings && info.NetworkSettings.Networks) || {};
|
|
218
|
+
args.push('--network', networks[NETWORK_NAME] ? NETWORK_NAME : Object.keys(networks).find(key => networks[key] && networks[key].IPAddress));
|
|
219
|
+
}
|
|
220
|
+
const hostConfig = info.HostConfig || {};
|
|
221
|
+
(hostConfig.Dns || []).filter(ip => net.isIP(ip)).forEach(ip => args.push('--dns', ip));
|
|
222
|
+
(hostConfig.ExtraHosts || []).filter(entry => /^[A-Za-z0-9._-]+:(?:host-gateway|[0-9a-fA-F:.]+)$/.test(entry)).forEach(entry => args.push('--add-host', entry));
|
|
223
|
+
args.push('--entrypoint', 'cat', getImageRef(), '/etc/hosts', '/etc/resolv.conf');
|
|
224
|
+
return await run(args, { timeout: 30000 });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 下发时现算:容器看到的宿主机 IP / DNS / 代理 / 镜像源 / peer 的当前 IP
|
|
228
|
+
async function resolveEndpoints(self, policy, all) {
|
|
229
|
+
const hostsText = await readRuntimeNetworkFacts(self.info);
|
|
230
|
+
const parsedHosts = parseHostsAndResolv(hostsText);
|
|
231
|
+
const dnsServers = parsedHosts.dnsServers;
|
|
232
|
+
// 没有 host.*.internal(docker 不带 --add-host 时)就用 bridge 的网关:它是宿主机上的 bridge 接口地址
|
|
233
|
+
const gatewayBridge = safeBridge(self.info);
|
|
234
|
+
const hostIps = parsedHosts.hostIps.length ? parsedHosts.hostIps : (gatewayBridge ? [gatewayBridge.gateway] : []);
|
|
235
|
+
const allow = [];
|
|
236
|
+
const addHost = (ports, proto) => hostIps.forEach(ip => allow.push({ ip, ports: String(ports), proto }));
|
|
237
|
+
|
|
238
|
+
// 必需端点:上游代理、过滤代理、Playwright、镜像源
|
|
239
|
+
const env = envMapOf(self.info);
|
|
240
|
+
const proxyTargets = [];
|
|
241
|
+
// allowlist 下容器只能经过滤代理出网,上游代理不能直连放行(否则绕过域名白名单)
|
|
242
|
+
if (policy.preset !== 'allowlist') {
|
|
243
|
+
Object.keys(env).filter(key => PROXY_ENV_KEYS.has(key)).forEach(key => {
|
|
244
|
+
const parsed = env[key] && parseUrlHostPort(env[key]);
|
|
245
|
+
if (parsed) proxyTargets.push(parsed);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
getMirrorUrls().forEach(url => {
|
|
249
|
+
const parsed = parseUrlHostPort(url);
|
|
250
|
+
if (parsed) proxyTargets.push(parsed);
|
|
251
|
+
});
|
|
252
|
+
for (const target of uniqueBy(proxyTargets, t => `${t.host}:${t.port}`)) {
|
|
253
|
+
for (const ip of await resolveHostToIps(target.host)) allow.push({ ip, ports: String(target.port), proto: 'tcp' });
|
|
254
|
+
}
|
|
255
|
+
// 过滤代理(serve 进程内):allowlist 容器经它出网;宿主机 IP 取容器视角的 host.containers.internal
|
|
256
|
+
let managedEnv = [];
|
|
257
|
+
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}`;
|
|
263
|
+
managedEnv = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].map(key => `${key}=${url}`);
|
|
264
|
+
}
|
|
265
|
+
playwrightHostPorts().forEach(port => addHost(port, 'tcp'));
|
|
266
|
+
policy.host.forEach(entry => addHost(entry.ports, entry.proto));
|
|
267
|
+
|
|
268
|
+
// 容器间:我的 peers.inbound 里的容器能进来;别人的 inbound 列了我,我才能出去
|
|
269
|
+
const peerIn = [];
|
|
270
|
+
const byId = new Map(all.map(item => [item.id, item]));
|
|
271
|
+
policy.peers.inbound.forEach(entry => {
|
|
272
|
+
const peer = byId.get(entry.from);
|
|
273
|
+
const b = peer && peer.running ? safeBridge(peer.info) : null;
|
|
274
|
+
if (b) peerIn.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
|
|
275
|
+
});
|
|
276
|
+
for (const other of all) {
|
|
277
|
+
if (other.id === self.id || !other.running || !state.stateExists(homeDir, other.id)) continue;
|
|
278
|
+
let otherPolicy;
|
|
279
|
+
try { otherPolicy = loadPolicy(other.id); } catch (e) { continue; }
|
|
280
|
+
const b = safeBridge(other.info);
|
|
281
|
+
if (!b) continue;
|
|
282
|
+
otherPolicy.peers.inbound.filter(entry => entry.from === self.id).forEach(entry => {
|
|
283
|
+
allow.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
managedEnv,
|
|
288
|
+
dns: dnsServers,
|
|
289
|
+
allow: uniqueBy(allow, a => `${a.ip}|${a.ports}|${a.proto}`),
|
|
290
|
+
peerIn,
|
|
291
|
+
bridge: bridgeOf(self.info) ? { subnet: bridgeOf(self.info).subnet, gateway: bridgeOf(self.info).gateway } : null
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function safeBridge(info) {
|
|
296
|
+
try { return bridgeOf(info); } catch (e) { return null; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function sendRuleset(name, text) {
|
|
300
|
+
await run([
|
|
301
|
+
'run', '--rm', '-i',
|
|
302
|
+
'--network', `container:${name}`,
|
|
303
|
+
'--user', 'root',
|
|
304
|
+
'--cap-drop', 'ALL', '--cap-add', 'NET_ADMIN',
|
|
305
|
+
'--security-opt', 'no-new-privileges',
|
|
306
|
+
'--entrypoint', 'nft',
|
|
307
|
+
'--pull=never',
|
|
308
|
+
getImageRef(),
|
|
309
|
+
'-f', '-'
|
|
310
|
+
], { input: text, timeout: 30000 });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// host / container: / none 网络模式下 helper 会把规则写进宿主机或别的容器的网络命名空间,一律不下发
|
|
314
|
+
function unsupportedNetworkMode(info) {
|
|
315
|
+
const mode = String((info.HostConfig && info.HostConfig.NetworkMode) || '');
|
|
316
|
+
return mode === 'host' || mode === 'none' || mode.startsWith('container:') ? mode : '';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const policyHashOf = policy => crypto.createHash('sha256').update(JSON.stringify(policy)).digest('hex').slice(0, 16);
|
|
320
|
+
|
|
321
|
+
async function clearGate(name) {
|
|
322
|
+
try {
|
|
323
|
+
await execInContainer(name, ['/bin/rm', '-f', GATE_READY]);
|
|
324
|
+
} catch (e) { /* 尽力:门闩清不掉也不影响“宿主机侧状态=未下发”的判断 */ }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function doApply(name, expectId) {
|
|
328
|
+
const all = await listManaged();
|
|
329
|
+
const self = all.find(item => item.name === name);
|
|
330
|
+
// 刚创建的容器必须能按 id 找到,找不到不能当“旧容器”放过(失败即关闭)
|
|
331
|
+
if (expectId && (!self || self.id !== expectId)) throw new Error(`网络规则下发失败: 找不到容器 ${name}(id ${expectId})`);
|
|
332
|
+
if (!self) return { status: 'legacy', message: '该容器创建于旧版本,重建后可用' };
|
|
333
|
+
// 状态目录不在本 HOME 下:这个容器属于别的 manyoyo 实例(另一个 HOME / 另一个 serve),不碰它的网络
|
|
334
|
+
if (!state.stateExists(homeDir, self.id)) return { status: 'foreign' };
|
|
335
|
+
if (!self.running) throw new Error(`容器 ${name} 未在运行`);
|
|
336
|
+
const startedAt = String((self.info.State && self.info.State.StartedAt) || '');
|
|
337
|
+
try {
|
|
338
|
+
const policy = loadPolicy(self.id);
|
|
339
|
+
const mode = unsupportedNetworkMode(self.info);
|
|
340
|
+
if (mode) {
|
|
341
|
+
state.setNetRequired(homeDir, self.id, false);
|
|
342
|
+
await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
|
|
343
|
+
const status = { status: 'unsupported', message: `容器使用 ${mode} 网络,无法下发网络规则`, startedAt, at: new Date().toISOString() };
|
|
344
|
+
state.writeNetStatus(homeDir, self.id, status);
|
|
345
|
+
return { ...status, id: self.id };
|
|
346
|
+
}
|
|
347
|
+
state.setNetRequired(homeDir, self.id, policy.preset !== 'open');
|
|
348
|
+
const endpoints = await resolveEndpoints(self, policy, all);
|
|
349
|
+
state.syncFilesEnv(homeDir, self.id); // 环境变量文件的最新快照(供自启动脚本用)
|
|
350
|
+
state.writeManagedEnv(homeDir, self.id, endpoints.managedEnv);
|
|
351
|
+
await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints));
|
|
352
|
+
await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
|
|
353
|
+
const privileged = Boolean(self.info.HostConfig && self.info.HostConfig.Privileged);
|
|
354
|
+
const status = {
|
|
355
|
+
status: 'applied',
|
|
356
|
+
startedAt,
|
|
357
|
+
policyHash: policyHashOf(policy),
|
|
358
|
+
warning: privileged && policy.preset !== 'open' ? '特权容器(dind / sock 模式)里的进程自带 NET_ADMIN,可以改动这些规则,网络限制没有约束力' : '',
|
|
359
|
+
at: new Date().toISOString()
|
|
360
|
+
};
|
|
361
|
+
state.writeNetStatus(homeDir, self.id, status);
|
|
362
|
+
return { ...status, id: self.id };
|
|
363
|
+
} catch (e) {
|
|
364
|
+
const message = `网络规则下发失败: ${e.message}`;
|
|
365
|
+
try { state.writeNetStatus(homeDir, self.id, { status: 'error', message, startedAt, at: new Date().toISOString() }); } catch (err) { /* 状态目录可能已被删 */ }
|
|
366
|
+
// 旧规则可能还在(比如 open → allowlist 失败):清掉门闩,让 manyoyo 的 exec 与自启动都按“未下发”处理
|
|
367
|
+
await clearGate(name);
|
|
368
|
+
const error = new Error(message);
|
|
369
|
+
error.cause = e;
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 同一容器的下发串行化,避免保存策略与 start 事件同时下发时互相覆盖
|
|
375
|
+
function apply(name, { expectId } = {}) {
|
|
376
|
+
const prev = locks.get(name) || Promise.resolve();
|
|
377
|
+
const next = prev.catch(() => {}).then(() => doApply(name, expectId));
|
|
378
|
+
locks.set(name, next);
|
|
379
|
+
next.finally(() => { if (locks.get(name) === next) locks.delete(name); }).catch(() => {});
|
|
380
|
+
return next;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* manyoyo 往容器里 exec 之前调用:策略要求网络规则而本次启动还没下发(没有门闩)就现在下发,
|
|
385
|
+
* 下发失败直接抛错,不放行。
|
|
386
|
+
*/
|
|
387
|
+
async function ensureReady(name) {
|
|
388
|
+
// inspect 失败(运行时抖动)不能当旧容器放行:直接抛错
|
|
389
|
+
const infos = await inspectMany([name]);
|
|
390
|
+
const info = infos[0];
|
|
391
|
+
const id = info && labelsOf(info)['manyoyo.id'];
|
|
392
|
+
if (!state.isValidId(id)) return { status: 'legacy' };
|
|
393
|
+
if (!state.stateExists(homeDir, id)) return { status: 'foreign' };
|
|
394
|
+
let policy;
|
|
395
|
+
try {
|
|
396
|
+
policy = loadPolicy(id);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
policy = null; // 策略文件损坏:按收紧处理,下发时会报错
|
|
399
|
+
}
|
|
400
|
+
if (policy && policy.preset === 'open') return { status: 'open' };
|
|
401
|
+
// 判断依据在宿主机侧(net-status.json:同一次启动 + 同一份策略 + 下发成功),不依赖容器里的任何文件或命令
|
|
402
|
+
const status = state.readNetStatus(homeDir, id);
|
|
403
|
+
const startedAt = String((info.State && info.State.StartedAt) || '');
|
|
404
|
+
const sameRun = status && status.startedAt === startedAt;
|
|
405
|
+
if (sameRun && status.status === 'unsupported') return { status: 'unsupported' };
|
|
406
|
+
if (policy && sameRun && status.status === 'applied' && status.policyHash === policyHashOf(policy)) return { status: 'applied' };
|
|
407
|
+
return await apply(name, { expectId: id });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 与 `name` 有 peers 关系的其他运行中容器(本容器重启后 IP 变化时要重算它们)
|
|
411
|
+
async function relatedContainers(name) {
|
|
412
|
+
const all = await listManaged();
|
|
413
|
+
const self = all.find(item => item.name === name);
|
|
414
|
+
if (!self) return [];
|
|
415
|
+
const related = [];
|
|
416
|
+
for (const other of all) {
|
|
417
|
+
if (other.id === self.id || !other.running || !state.stateExists(homeDir, other.id)) continue;
|
|
418
|
+
let p;
|
|
419
|
+
let mine;
|
|
420
|
+
try { p = loadPolicy(other.id); mine = loadPolicy(self.id); } catch (e) { continue; }
|
|
421
|
+
if (p.peers.inbound.some(e => e.from === self.id) || mine.peers.inbound.some(e => e.from === other.id)) related.push(other.name);
|
|
422
|
+
}
|
|
423
|
+
return related;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 启动 / 重连时把所有运行中的受管容器过一遍门闩:serve 没在时被外部重启的容器,现在补下发
|
|
427
|
+
async function reconcileAll() {
|
|
428
|
+
const all = await listManaged();
|
|
429
|
+
for (const item of all.filter(i => i.running)) {
|
|
430
|
+
try {
|
|
431
|
+
await ensureReady(item.name);
|
|
432
|
+
} catch (e) {
|
|
433
|
+
warn(`容器 ${item.name} 网络规则下发失败: ${e.message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 订阅容器 start 事件:容器被外部重启(podman restart、machine 重启后手动 start)后 netns 重建、规则丢失,
|
|
439
|
+
// 事件到来就重新下发,并重算与它有 peers 关系的容器(它的 IP 可能变了)。
|
|
440
|
+
function watchStarts() {
|
|
441
|
+
let stopped = false;
|
|
442
|
+
let child = null;
|
|
443
|
+
let delay = 1000;
|
|
444
|
+
let retryTimer = null;
|
|
445
|
+
const timers = new Map();
|
|
446
|
+
|
|
447
|
+
// start:重新下发并重算相关容器;die:只重算相关容器(它的 IP 会被回收,别人规则里的旧 IP 要去掉)
|
|
448
|
+
const onEvent = (name, status) => {
|
|
449
|
+
const key = `${status}:${name}`;
|
|
450
|
+
clearTimeout(timers.get(key));
|
|
451
|
+
timers.set(key, setTimeout(async () => {
|
|
452
|
+
timers.delete(key);
|
|
453
|
+
try {
|
|
454
|
+
if (status === 'start') await apply(name);
|
|
455
|
+
for (const related of await relatedContainers(name, { includeStopped: status === 'die' })) {
|
|
456
|
+
await apply(related).catch(e => warn(`容器 ${related} 网络规则重算失败: ${e.message}`));
|
|
457
|
+
}
|
|
458
|
+
} catch (e) {
|
|
459
|
+
warn(`容器 ${name} 网络规则下发失败: ${e.message}`);
|
|
460
|
+
}
|
|
461
|
+
}, 300));
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const parseEvent = line => {
|
|
465
|
+
let event;
|
|
466
|
+
try { event = JSON.parse(line); } catch (e) { return null; }
|
|
467
|
+
const attrs = event.Attributes || (event.Actor && event.Actor.Attributes) || {};
|
|
468
|
+
const name = event.Name || attrs.name;
|
|
469
|
+
const status = event.Status || event.status || event.Action;
|
|
470
|
+
if ((status !== 'start' && status !== 'die') || !name || !state.isValidId(attrs['manyoyo.id'])) return null;
|
|
471
|
+
return { name, status };
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const connect = () => {
|
|
475
|
+
if (stopped) return;
|
|
476
|
+
try {
|
|
477
|
+
child = spawn(command, ['events', '--filter', 'event=start', '--filter', 'event=die', '--filter', 'type=container', '--format', '{{json .}}'], {
|
|
478
|
+
env: runtimeEnv, stdio: ['ignore', 'pipe', 'ignore']
|
|
479
|
+
});
|
|
480
|
+
} catch (e) {
|
|
481
|
+
retryTimer = setTimeout(connect, delay);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
let pending = '';
|
|
485
|
+
child.stdout.on('data', chunk => {
|
|
486
|
+
delay = 1000;
|
|
487
|
+
pending += chunk;
|
|
488
|
+
const lines = pending.split('\n');
|
|
489
|
+
pending = lines.pop();
|
|
490
|
+
lines.forEach(line => {
|
|
491
|
+
const parsed = line.trim() && parseEvent(line);
|
|
492
|
+
if (parsed) onEvent(parsed.name, parsed.status);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
const again = () => {
|
|
496
|
+
if (stopped) return;
|
|
497
|
+
delay = Math.min(delay * 2, 30000);
|
|
498
|
+
retryTimer = setTimeout(connect, delay);
|
|
499
|
+
};
|
|
500
|
+
child.on('error', again);
|
|
501
|
+
child.on('close', again);
|
|
502
|
+
reconcileAll().catch(e => warn(`网络规则对账失败: ${e.message}`));
|
|
503
|
+
};
|
|
504
|
+
connect();
|
|
505
|
+
return {
|
|
506
|
+
stop() {
|
|
507
|
+
stopped = true;
|
|
508
|
+
clearTimeout(retryTimer);
|
|
509
|
+
timers.forEach(timer => clearTimeout(timer));
|
|
510
|
+
if (child && !child.killed) child.kill('SIGTERM');
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { NETWORK_NAME, run, listManaged, loadPolicy, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
module.exports = {
|
|
519
|
+
NETWORK_NAME,
|
|
520
|
+
GATE_READY,
|
|
521
|
+
subnetOf,
|
|
522
|
+
bridgeOf,
|
|
523
|
+
parseHostsAndResolv,
|
|
524
|
+
parseUrlHostPort,
|
|
525
|
+
createNetworkManager
|
|
526
|
+
};
|
package/lib/container-run.js
CHANGED
|
@@ -9,22 +9,39 @@ function sanitizeDefaultCommand(defaultCommand) {
|
|
|
9
9
|
return String(defaultCommand || '').replace(/[\r\n]/g, ' ');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// 状态目录挂进容器的位置;PID 1 是 manyoyo 自带的 init(回收僵尸、响应 SIGTERM、跑自启动脚本)
|
|
13
|
+
const BOX_MOUNT = '/run/manyoyo';
|
|
14
|
+
const SYS_MOUNT = '/run/manyoyo-sys';
|
|
15
|
+
const GATE_MOUNT = '/run/manyoyo-gate';
|
|
16
|
+
|
|
17
|
+
// 用户已经自己指定网络(--network / --net)时不再加默认的 manyoyo bridge
|
|
18
|
+
function hasNetworkArg(args) {
|
|
19
|
+
return (args || []).some(arg => /^--(network|net)(=|$)/.test(String(arg)));
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
function buildContainerRunArgs(options) {
|
|
23
|
+
const st = options.state;
|
|
24
|
+
if (!st || !st.id) throw new Error('缺少容器状态目录');
|
|
13
25
|
const args = [
|
|
14
26
|
'run', '-d',
|
|
15
27
|
'--name', options.containerName,
|
|
16
|
-
'--entrypoint',
|
|
28
|
+
'--entrypoint', `${SYS_MOUNT}/init.sh`,
|
|
17
29
|
...(options.contModeArgs || []),
|
|
18
30
|
...(options.containerExtraArgs || []),
|
|
31
|
+
...(options.defaultNetwork && !hasNetworkArg([...(options.contModeArgs || []), ...(options.containerExtraArgs || [])])
|
|
32
|
+
? ['--network', options.defaultNetwork] : []),
|
|
19
33
|
...(options.containerEnvs || []),
|
|
20
34
|
...buildMirrorEnvArgs(options.mirrors, options.containerEnvs),
|
|
21
35
|
...(options.containerVolumes || []),
|
|
22
36
|
...(options.containerPorts || []),
|
|
37
|
+
'--volume', `${st.box}:${BOX_MOUNT}`,
|
|
38
|
+
'--volume', `${st.sys}:${SYS_MOUNT}:ro`,
|
|
39
|
+
'--tmpfs', GATE_MOUNT,
|
|
40
|
+
'--label', `manyoyo.id=${st.id}`,
|
|
23
41
|
'--volume', `${options.hostPath}:${options.containerPath}`,
|
|
24
42
|
'--workdir', options.containerPath,
|
|
25
43
|
'--label', `manyoyo.default_cmd=${sanitizeDefaultCommand(options.defaultCommand)}`,
|
|
26
|
-
`${options.imageName}:${options.imageVersion}
|
|
27
|
-
'tail', '-f', '/dev/null'
|
|
44
|
+
`${options.imageName}:${options.imageVersion}`
|
|
28
45
|
];
|
|
29
46
|
return args;
|
|
30
47
|
}
|
|
@@ -74,6 +91,10 @@ function buildContainerRunCommand(dockerCmd, args) {
|
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
module.exports = {
|
|
94
|
+
hasNetworkArg,
|
|
95
|
+
BOX_MOUNT,
|
|
96
|
+
SYS_MOUNT,
|
|
97
|
+
GATE_MOUNT,
|
|
77
98
|
buildContainerRunArgs,
|
|
78
99
|
runWithEnvFile,
|
|
79
100
|
buildContainerRunCommand
|