@xcanwin/manyoyo 8.3.3 → 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/README.md +1 -1
- package/bin/manyoyo.js +111 -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/browser-background.js +23 -0
- package/lib/container-env.sh +34 -0
- package/lib/container-exec.js +132 -0
- package/lib/container-id.js +11 -0
- package/lib/container-init.sh +40 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +615 -0
- package/lib/container-run.js +27 -3
- package/lib/container-state.js +467 -0
- package/lib/egress-denied.js +128 -0
- package/lib/egress-proxy.js +315 -0
- package/lib/egress-sidecar-main.js +112 -0
- package/lib/egress-sidecar.js +276 -0
- package/lib/env-text.js +62 -0
- package/lib/json5-text-edit.js +5 -5
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +269 -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 +137 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +506 -0
- package/lib/web/denied-notify.js +52 -0
- package/lib/web/index.html +81 -77
- package/lib/web/server.js +352 -135
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const state = require('./container-state');
|
|
8
|
+
|
|
9
|
+
const ID_LABEL = 'manyoyo.id';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 读容器的 manyoyo.id 标签;没有标签(旧容器)或容器不存在返回 ''。
|
|
13
|
+
* @param {(args: string[], options?: object) => string} dockerExecArgs
|
|
14
|
+
*/
|
|
15
|
+
function resolveContainerId(dockerExecArgs, containerName) {
|
|
16
|
+
try {
|
|
17
|
+
const out = String(dockerExecArgs(
|
|
18
|
+
['inspect', '-f', `{{index .Config.Labels "${ID_LABEL}"}}`, containerName],
|
|
19
|
+
{ stdio: 'pipe' }
|
|
20
|
+
) || '').trim();
|
|
21
|
+
return state.isValidId(out) ? out : '';
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// managed.env 在只读挂载的 sys/ 里(容器改不了);box/env 是容器可写的,按不可信输入读取(见 container-state.js)
|
|
28
|
+
function readTextOrEmpty(file, untrusted = false) {
|
|
29
|
+
if (untrusted) return state.readBoxText(file, state.MAX_ENV_BYTES).text;
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(file, 'utf-8');
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 合成一次 exec 的 env 行:managed → 用户 env(box/env)→ extra,后者覆盖前者,非法行一律丢弃。
|
|
39
|
+
* podman 对非法行(1BAD=x、含空格的 key)会原样传进容器,所以必须由宿主机先过滤。
|
|
40
|
+
* @returns {{lines: string[], invalid: object[]}}
|
|
41
|
+
*/
|
|
42
|
+
function composeEnvLines(managedText, boxText, extraLines = [], fileEntries = []) {
|
|
43
|
+
const merged = new Map();
|
|
44
|
+
const invalid = [];
|
|
45
|
+
const take = (text, source) => {
|
|
46
|
+
const parsed = state.parseEnvText(text);
|
|
47
|
+
parsed.entries.forEach(entry => merged.set(entry.key, entry.value));
|
|
48
|
+
parsed.invalid.forEach(item => invalid.push({ ...item, source }));
|
|
49
|
+
};
|
|
50
|
+
take(managedText, 'managed');
|
|
51
|
+
// 环境变量文件(已解析、已校验的条目)排在 managed 之后、box/env 之前:表格里的变量优先于文件
|
|
52
|
+
(fileEntries || []).forEach(entry => merged.set(entry.key, entry.value));
|
|
53
|
+
take(boxText, 'box');
|
|
54
|
+
// extra 是命令行 / 配置里已经成形的 KEY=VALUE(值是字面量,不再做去引号)
|
|
55
|
+
(extraLines || []).forEach(line => {
|
|
56
|
+
const i = String(line).indexOf('=');
|
|
57
|
+
if (i > 0 && state.ENV_KEY_RE.test(line.slice(0, i))) merged.set(line.slice(0, i), line.slice(i + 1));
|
|
58
|
+
else invalid.push({ line: 0, text: String(line), reason: '缺少 KEY=VALUE', source: 'extra' });
|
|
59
|
+
});
|
|
60
|
+
// 写给 docker / podman 的 --env-file:值是字面量(它们不处理引号),所以这里一律原样输出
|
|
61
|
+
return { lines: [...merged].map(([key, value]) => `${key}=${value}`), invalid };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeTempEnvFile(homeDir, lines) {
|
|
65
|
+
const dir = path.join(homeDir, '.manyoyo', 'tmp');
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
67
|
+
fs.chmodSync(dir, 0o700);
|
|
68
|
+
const file = path.join(dir, `exec-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
69
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 所有进入用户容器的 `exec` 都经这里构造参数(Agent、终端、CLI 命令、first 预执行)。
|
|
75
|
+
* 用户 env 与 manyoyo 注入的 env 放在宿主机文件里,每次 exec 现读,所以改了下一条命令就生效;
|
|
76
|
+
* 值通过 0600 临时 env 文件传入,不出现在 ps 里;调用方必须在子进程启动后(或结束时)调 cleanup()。
|
|
77
|
+
*
|
|
78
|
+
* @param {{homeDir?: string, dockerExecArgs: Function}} ctx
|
|
79
|
+
* @param {string} containerName
|
|
80
|
+
* @param {object} options
|
|
81
|
+
* @param {boolean} [options.interactive] -i
|
|
82
|
+
* @param {boolean} [options.tty] -t
|
|
83
|
+
* @param {string} [options.user]
|
|
84
|
+
* @param {string[]} [options.envs] 非敏感的 KEY=VALUE(TERM 等),以 --env 传入
|
|
85
|
+
* @param {string[]} [options.extraEnv] 敏感/用户 KEY=VALUE(first.env),进临时文件,优先级最高
|
|
86
|
+
* @param {boolean} [options.withEnv=true] false 时不加载用户/managed env(读文件等与环境无关的 exec)
|
|
87
|
+
* @param {string[]} options.command 容器内要运行的命令与参数
|
|
88
|
+
* @returns {{args: string[], cleanup: () => void, invalid: object[], containerId: string}}
|
|
89
|
+
*/
|
|
90
|
+
function buildExecArgs(ctx, containerName, options = {}) {
|
|
91
|
+
const homeDir = ctx.homeDir || os.homedir();
|
|
92
|
+
const withEnv = options.withEnv !== false;
|
|
93
|
+
const args = ['exec'];
|
|
94
|
+
if (options.interactive) args.push('-i');
|
|
95
|
+
if (options.tty) args.push('-t');
|
|
96
|
+
if (options.user) args.push('--user', options.user);
|
|
97
|
+
|
|
98
|
+
let containerId = '';
|
|
99
|
+
let lines = [];
|
|
100
|
+
let invalid = [];
|
|
101
|
+
const extra = options.extraEnv || [];
|
|
102
|
+
if (withEnv) {
|
|
103
|
+
containerId = resolveContainerId(ctx.dockerExecArgs, containerName);
|
|
104
|
+
if (containerId && state.stateExists(homeDir, containerId)) {
|
|
105
|
+
const p = state.paths(homeDir, containerId);
|
|
106
|
+
({ lines, invalid } = composeEnvLines(readTextOrEmpty(p.managedEnv), readTextOrEmpty(p.env, true), extra, state.envFileEntries(homeDir, containerId)));
|
|
107
|
+
} else {
|
|
108
|
+
containerId = '';
|
|
109
|
+
({ lines, invalid } = composeEnvLines('', '', extra));
|
|
110
|
+
}
|
|
111
|
+
} else if (extra.length) {
|
|
112
|
+
({ lines, invalid } = composeEnvLines('', '', extra));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let envFile = '';
|
|
116
|
+
if (lines.length) {
|
|
117
|
+
envFile = writeTempEnvFile(homeDir, lines);
|
|
118
|
+
args.push('--env-file', envFile);
|
|
119
|
+
}
|
|
120
|
+
(options.envs || []).forEach(entry => args.push('--env', entry));
|
|
121
|
+
args.push(containerName, ...(options.command || []));
|
|
122
|
+
|
|
123
|
+
let cleaned = false;
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
if (cleaned || !envFile) return;
|
|
126
|
+
cleaned = true;
|
|
127
|
+
try { fs.rmSync(envFile, { force: true }); } catch (e) { /* 尽力清理 */ }
|
|
128
|
+
};
|
|
129
|
+
return { args, cleanup, invalid, containerId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { ID_LABEL, resolveContainerId, composeEnvLines, buildExecArgs };
|
|
@@ -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 };
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
. "$SYS/env.sh"
|
|
14
|
+
|
|
15
|
+
# 日志超过 256 KiB 时只保留尾部 128 KiB
|
|
16
|
+
if [ -f "$LOG" ] && [ "$(stat -c %s "$LOG" 2>/dev/null || echo 0)" -gt 262144 ]; then
|
|
17
|
+
tail -c 131072 "$LOG" > "$LOG.tmp" 2>/dev/null && mv -f "$LOG.tmp" "$LOG"
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
run_autostart() {
|
|
21
|
+
if [ -e "$SYS/net-required" ]; then
|
|
22
|
+
log "等待 manyoyo 下发网络规则"
|
|
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
|
|
29
|
+
fi
|
|
30
|
+
load_env "$SYS/managed.env"
|
|
31
|
+
load_env "$SYS/files.env"
|
|
32
|
+
load_env "$BOX/env"
|
|
33
|
+
log "开始运行自启动脚本"
|
|
34
|
+
/bin/bash "$BOX/autostart.sh" >> "$LOG" 2>&1
|
|
35
|
+
log "自启动脚本退出,退出码 $?"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setsid bash -c "$(declare -f log load_env run_autostart); BOX=$BOX SYS=$SYS GATE=$GATE LOG=$LOG; run_autostart" >/dev/null 2>&1 &
|
|
39
|
+
|
|
40
|
+
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 };
|