@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/lib/network-policy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const net = require('net');
|
|
4
|
-
const { isValidId } = require('./container-
|
|
4
|
+
const { isValidId } = require('./container-id');
|
|
5
5
|
|
|
6
6
|
// 容器网络策略:校验(唯一入口)+ 生成 nft 规则文本。
|
|
7
7
|
// nft 文本只由校验过的结构化数据生成:IP/CIDR 经 net.isIP + 前缀整数、端口是 1-65535 的整数、域名不进规则
|
|
@@ -196,6 +196,7 @@ function cidrList(cidrs, family) {
|
|
|
196
196
|
* @param {string[]} [endpoints.dns]
|
|
197
197
|
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo 必需端点、上游代理、过滤代理、Playwright、镜像、host 端口、出站 peer
|
|
198
198
|
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.peerIn] 允许进来的 peer
|
|
199
|
+
* @param {{daddr:string, saddr:string}[]} [endpoints.antiSpoof] 发往 daddr 的包来源必须是 saddr(过滤代理按来源 IP 认容器)
|
|
199
200
|
* @param {{subnet:string, gateway:string}|null} [endpoints.bridge]
|
|
200
201
|
* @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);open 预设只删表
|
|
201
202
|
*/
|
|
@@ -204,6 +205,9 @@ function buildNftRuleset(policy, endpoints = {}) {
|
|
|
204
205
|
if (policy.preset === 'open') return `${lines.join('\n')}\n`;
|
|
205
206
|
|
|
206
207
|
const out = [' oif lo accept', ' ct state established,related accept'];
|
|
208
|
+
(endpoints.antiSpoof || []).forEach(item => {
|
|
209
|
+
out.push(` ${addrExpr('daddr', item.daddr, 'antiSpoof')} ${addrExpr('saddr', item.saddr, 'antiSpoof').replace(' saddr ', ' saddr != ')} drop`);
|
|
210
|
+
});
|
|
207
211
|
// 用户 deny 排在所有放行规则之前,才能真正“禁止”
|
|
208
212
|
if (policy.preset !== 'open') policy.deny.forEach(rule => out.push(` ${addrExpr('daddr', rule.cidr, 'deny')} reject`));
|
|
209
213
|
(endpoints.dns || []).forEach(ip => {
|
package/lib/port-forward.js
CHANGED
|
@@ -8,13 +8,15 @@ const { buildExecArgs } = require('./container-exec');
|
|
|
8
8
|
// 运行中暴露容器端口:serve 在宿主机监听 bind:hostPort,每个连接起一个 `exec -i <容器> socat STDIO TCP:127.0.0.1:<端口>` 双向 pipe。
|
|
9
9
|
// 不需要重启容器、不依赖容器的网络模式(slirp4netns / bridge / pasta 都一样)。
|
|
10
10
|
const MAX_CONNECTIONS_PER_FORWARD = 64;
|
|
11
|
+
// 连接空闲(双向都没有数据)超过这个时间就断开,socat 与 node 兜底一致
|
|
12
|
+
const IDLE_SECONDS = 2 * 60 * 60;
|
|
11
13
|
|
|
12
14
|
// 容器里优先 socat,没有就用 node 兜底;端口是校验过的整数
|
|
13
15
|
function containerCommand(port) {
|
|
14
16
|
const p = Number(port);
|
|
15
17
|
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`端口非法: ${port}`);
|
|
16
|
-
const nodeScript = `const s=require('net').connect(${p},'127.0.0.1');process.stdin.pipe(s);s.pipe(process.stdout);s.on('error',()=>process.exit(1));s.on('close',()=>process.exit(0))`;
|
|
17
|
-
return ['/bin/sh', '-c', `if command -v socat >/dev/null 2>&1; then exec socat STDIO TCP:127.0.0.1:${p}; else exec node -e "${nodeScript}"; fi`];
|
|
18
|
+
const nodeScript = `const s=require('net').connect(${p},'127.0.0.1');s.setTimeout(${IDLE_SECONDS}*1000,()=>process.exit(0));process.stdin.pipe(s);s.pipe(process.stdout);s.on('error',()=>process.exit(1));s.on('close',()=>process.exit(0))`;
|
|
19
|
+
return ['/bin/sh', '-c', `if command -v socat >/dev/null 2>&1; then exec socat -T ${IDLE_SECONDS} STDIO TCP:127.0.0.1:${p}; else exec node -e "${nodeScript}"; fi`];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
function keyOf(id, bind, hostPort) {
|
|
@@ -132,4 +134,4 @@ function createPortForwarder(options) {
|
|
|
132
134
|
return { open, close, closeFor, closeAll, list, sync };
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
module.exports = { createPortForwarder, containerCommand, MAX_CONNECTIONS_PER_FORWARD };
|
|
137
|
+
module.exports = { createPortForwarder, containerCommand, MAX_CONNECTIONS_PER_FORWARD, IDLE_SECONDS };
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
## 容器管理接口
|
|
25
25
|
|
|
26
|
-
`lib/web/container-manage.js`(路由工厂,由 `handleWebApi` 追加进路由表):`/api/containers/:name/{env,autostart,autostart/run,autostart/log,network,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag)防止覆盖容器内的修改;放开类改动(`open`、非 loopback 暴露)服务端也要求 `confirmRisk:true`。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start)补下发规则、拉起 `autostartOnServe`
|
|
26
|
+
`lib/web/container-manage.js`(路由工厂,由 `handleWebApi` 追加进路由表):`/api/containers/:name/{env,autostart,autostart/run,autostart/log,network,network/allow,network/denied,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag)防止覆盖容器内的修改;放开类改动(`open`、非 loopback 暴露)服务端也要求 `confirmRisk:true`。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start / die)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;事件流断开重连(运行时重启)后 `reconcileContainerServices` 再对账一次;过滤代理是 sidecar 容器(`lib/egress-sidecar.js`),由网络管理器 `ensure()` 保证在运行,不在 serve 进程里。`GET network` 带 `denied`(最近被拦截,已放行的不列);`/agent/stream` 进行中 `createWebDeniedNotifier` 每 2 秒读一次拦截记录,有新域名就推一条 `trace`(只有 `text`,同一域名同一轮一次)。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
|
|
27
27
|
|
|
28
28
|
## lib/web/index.html
|
|
29
29
|
|
|
@@ -7,6 +7,10 @@ const { resolveContainerId } = require('../container-exec');
|
|
|
7
7
|
const { normalizePolicy } = require('../network-policy');
|
|
8
8
|
const { parseEnvEntry } = require('../runtime-normalizers');
|
|
9
9
|
const { envUrlDomains } = require('../network-endpoints');
|
|
10
|
+
const { egressPaths } = require('../egress-sidecar');
|
|
11
|
+
const deniedLib = require('../egress-denied');
|
|
12
|
+
const { isBrowserBackground } = require('../browser-background');
|
|
13
|
+
const { domainAllowed } = require('../network-policy');
|
|
10
14
|
|
|
11
15
|
// 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
|
|
12
16
|
// 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
|
|
@@ -117,6 +121,17 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
117
121
|
};
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
// 最近被过滤代理拦截的域名(已在白名单里的不再列出);background 标记浏览器自己的后台请求
|
|
125
|
+
function deniedPayload(id, policy) {
|
|
126
|
+
try {
|
|
127
|
+
return deniedLib.read(egressPaths(homeDir).denied, id)
|
|
128
|
+
.filter(record => !domainAllowed(policy.egress.domains, record.host))
|
|
129
|
+
.map(record => ({ ...record, background: isBrowserBackground(record.host) }));
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
async function networkPayload(target, extra = {}) {
|
|
121
136
|
const policy = readPolicy(target.id);
|
|
122
137
|
const manager = getNetworkManager();
|
|
@@ -138,6 +153,7 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
138
153
|
id: target.id,
|
|
139
154
|
running: isRunning(target.name),
|
|
140
155
|
suggestedDomains,
|
|
156
|
+
denied: deniedPayload(target.id, policy),
|
|
141
157
|
policy,
|
|
142
158
|
status: state_.readNetStatus(homeDir, target.id),
|
|
143
159
|
forwards: getPortForwarder().list(target.id),
|
|
@@ -193,6 +209,21 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
193
209
|
else sendJson(res, 200, payload);
|
|
194
210
|
}
|
|
195
211
|
|
|
212
|
+
// 一键放行被拦截的域名:加进白名单并立即下发
|
|
213
|
+
async function postAllow(target, body) {
|
|
214
|
+
const domain = String(body.domain || '').trim().toLowerCase();
|
|
215
|
+
const policy = readPolicy(target.id);
|
|
216
|
+
if (policy.preset !== 'allowlist') {
|
|
217
|
+
sendJson(res, 400, { error: '只有「仅白名单」出站模式才需要放行域名' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!policy.egress.domains.includes(domain)) {
|
|
221
|
+
await putNetwork(target, { policy: { ...policy, egress: { ...policy.egress, domains: [...policy.egress.domains, domain] } }, confirmRisk: body.confirmRisk });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
sendJson(res, 200, await networkPayload(target));
|
|
225
|
+
}
|
|
226
|
+
|
|
196
227
|
async function postExpose(target, body) {
|
|
197
228
|
const policy = readPolicy(target.id);
|
|
198
229
|
const entry = { bind: body.bind, hostPort: body.hostPort, port: body.port };
|
|
@@ -419,6 +450,31 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
419
450
|
}
|
|
420
451
|
}
|
|
421
452
|
},
|
|
453
|
+
{
|
|
454
|
+
method: 'POST',
|
|
455
|
+
match: p => p.match(/^\/api\/containers\/([^/]+)\/network\/allow$/),
|
|
456
|
+
handler: async match => {
|
|
457
|
+
const target = resolveTarget(match[1], { write: true });
|
|
458
|
+
if (!target) return;
|
|
459
|
+
const body = await readJsonBody(req);
|
|
460
|
+
try {
|
|
461
|
+
await postAllow(target, body);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if (res.headersSent) throw e;
|
|
464
|
+
sendJson(res, 400, { error: e.message });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
method: 'DELETE',
|
|
470
|
+
match: p => p.match(/^\/api\/containers\/([^/]+)\/network\/denied$/),
|
|
471
|
+
handler: async match => {
|
|
472
|
+
const target = resolveTarget(match[1], { write: true });
|
|
473
|
+
if (!target) return;
|
|
474
|
+
deniedLib.clear(egressPaths(homeDir).denied, target.id);
|
|
475
|
+
sendJson(res, 200, await networkPayload(target));
|
|
476
|
+
}
|
|
477
|
+
},
|
|
422
478
|
{
|
|
423
479
|
method: 'POST',
|
|
424
480
|
match: p => p.match(/^\/api\/containers\/([^/]+)\/expose$/),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Agent 一轮对话进行中,容器里有新的域名被过滤代理拦下时,立即在对话里提示一次(同一域名同一轮只提示一次)。
|
|
4
|
+
// 只轮询拦截记录文件(每 2 秒读一个小文件),不写任何日志。
|
|
5
|
+
const DEFAULT_INTERVAL_MS = 2000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {() => {host: string, port: number, count: number}[]} options.read 当前拦截记录
|
|
10
|
+
* @param {(host: string) => boolean} options.isIgnored 不提示的域名(已放行、浏览器后台请求)
|
|
11
|
+
* @param {(text: string) => void} options.emit
|
|
12
|
+
*/
|
|
13
|
+
function createDeniedNotifier(options) {
|
|
14
|
+
const intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
|
|
15
|
+
const baseline = new Map();
|
|
16
|
+
const notified = new Set();
|
|
17
|
+
let timer = null;
|
|
18
|
+
|
|
19
|
+
function snapshot() {
|
|
20
|
+
try { return options.read() || []; } catch (e) { return []; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 在定时器里跑:任何异常(含 emit 里写历史失败)都不能冒泡成 uncaughtException 把 serve 打挂
|
|
24
|
+
function tick() {
|
|
25
|
+
try {
|
|
26
|
+
snapshot().forEach(record => {
|
|
27
|
+
const key = `${record.host}:${record.port}`;
|
|
28
|
+
if (record.count > (baseline.get(key) || 0) && !notified.has(record.host) && !options.isIgnored(record.host)) {
|
|
29
|
+
notified.add(record.host);
|
|
30
|
+
options.emit(`[提示] 已拦截:${record.host}(不在白名单),可在「容器 → 网络」里一键允许`);
|
|
31
|
+
}
|
|
32
|
+
baseline.set(key, record.count);
|
|
33
|
+
});
|
|
34
|
+
} catch (e) { /* 下一轮再试 */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
start() {
|
|
39
|
+
snapshot().forEach(record => baseline.set(`${record.host}:${record.port}`, record.count));
|
|
40
|
+
timer = setInterval(tick, intervalMs);
|
|
41
|
+
if (timer.unref) timer.unref();
|
|
42
|
+
},
|
|
43
|
+
tick,
|
|
44
|
+
stop() {
|
|
45
|
+
if (timer) clearInterval(timer);
|
|
46
|
+
timer = null;
|
|
47
|
+
tick(); // 最后一次:对话结束前的拦截也提示
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { createDeniedNotifier };
|