@xcanwin/manyoyo 8.4.0 → 8.5.1
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 +66 -25
- package/bin/manyoyo.js +48 -9
- package/lib/app-update.js +93 -35
- 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/net-proxy.js +183 -0
- package/lib/network-policy.js +5 -1
- package/lib/plugin/playwright.js +4 -2
- 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 +5 -3
- package/README.en.md +0 -88
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 };
|
package/lib/egress-proxy.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const crypto = require('crypto');
|
|
4
3
|
const dns = require('dns').promises;
|
|
5
4
|
const http = require('http');
|
|
6
5
|
const net = require('net');
|
|
7
6
|
const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
|
|
8
|
-
const { isValidId } = require('./container-state');
|
|
9
7
|
|
|
10
|
-
//
|
|
11
|
-
// 开启 allowlist
|
|
8
|
+
// 域名白名单的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
|
|
9
|
+
// 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按连接的来源 IP 认出是哪个容器(getClient),
|
|
10
|
+
// 认不出的来源一律 403;按域名放行 CONNECT / 普通 HTTP,
|
|
12
11
|
// 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
|
|
13
|
-
// 除非该容器的 egress.rules 显式放行该 IP
|
|
14
|
-
|
|
12
|
+
// 除非该容器的 egress.rules 显式放行该 IP:端口。
|
|
13
|
+
// 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
|
|
15
14
|
const CONNECT_TIMEOUT_MS = 15000;
|
|
16
15
|
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
17
16
|
const MAX_TUNNELS_PER_CONTAINER = 256;
|
|
@@ -65,21 +64,6 @@ function ruleCovers(rule, ip, port) {
|
|
|
65
64
|
return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
function parseBasicAuth(header) {
|
|
69
|
-
const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(String(header || '').trim());
|
|
70
|
-
if (!m) return null;
|
|
71
|
-
const decoded = Buffer.from(m[1], 'base64').toString('utf-8');
|
|
72
|
-
const i = decoded.indexOf(':');
|
|
73
|
-
if (i < 0) return null;
|
|
74
|
-
return { id: decoded.slice(0, i), token: decoded.slice(i + 1) };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function tokenEquals(a, b) {
|
|
78
|
-
const x = Buffer.from(String(a));
|
|
79
|
-
const y = Buffer.from(String(b));
|
|
80
|
-
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
67
|
function parseTarget(text) {
|
|
84
68
|
const m = /^(?:\[([0-9a-fA-F:.]+)\]|([^:/\s]+)):(\d{1,5})$/.exec(text);
|
|
85
69
|
if (!m) return null;
|
|
@@ -90,40 +74,53 @@ function parseTarget(text) {
|
|
|
90
74
|
|
|
91
75
|
/**
|
|
92
76
|
* @param {object} options
|
|
93
|
-
* @param {(
|
|
94
|
-
* @param {(id: string
|
|
77
|
+
* @param {(ip: string) => {id: string, preset: string, domains: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
|
|
78
|
+
* @param {(event: {id: string, host: string, port: number, reason: string}) => void} [options.onDenied] 拒绝记录
|
|
95
79
|
* @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
|
|
96
80
|
* @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
|
|
97
81
|
*/
|
|
98
82
|
function createEgressProxy(options) {
|
|
99
|
-
const
|
|
100
|
-
const
|
|
83
|
+
const getClient = options.getClient;
|
|
84
|
+
const onDenied = options.onDenied || (() => {});
|
|
101
85
|
const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
86
|
+
// upstream 可以是字符串或返回字符串的函数(sidecar 里按文件改动热更新);解析结果按字符串缓存
|
|
87
|
+
let parsedFor = null;
|
|
88
|
+
let parsedUpstream = null;
|
|
89
|
+
function currentUpstream() {
|
|
90
|
+
const text = typeof options.upstream === 'function' ? options.upstream() : options.upstream;
|
|
91
|
+
if (text !== parsedFor) {
|
|
92
|
+
parsedFor = text;
|
|
93
|
+
parsedUpstream = null;
|
|
94
|
+
if (text) {
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(text);
|
|
97
|
+
if (url.protocol === 'http:') {
|
|
98
|
+
parsedUpstream = { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80, auth: url.username ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' };
|
|
99
|
+
}
|
|
100
|
+
} catch (e) { /* 无效上游当作没有 */ }
|
|
108
101
|
}
|
|
109
|
-
}
|
|
102
|
+
}
|
|
103
|
+
return parsedUpstream;
|
|
110
104
|
}
|
|
111
105
|
const tunnels = new Map();
|
|
112
106
|
const sockets = new Set(); // CONNECT 劫持出来的 socket 不在 http.Server 的跟踪里,stop 时要自己关
|
|
113
107
|
let server = null;
|
|
114
108
|
|
|
115
|
-
function
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
function normalizeSource(address) {
|
|
110
|
+
const text = String(address || '');
|
|
111
|
+
return /^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(text) ? text.slice(7) : text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 按来源 IP 认容器:返回 {id, policy},认不出返回 null
|
|
115
|
+
function identify(socket) {
|
|
116
|
+
let client = null;
|
|
117
|
+
try { client = getClient(normalizeSource(socket.remoteAddress)); } catch (e) { client = null; }
|
|
118
|
+
return client && client.id ? { id: client.id, policy: client } : null;
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
// 返回 {ok:true, ip?} 或 {ok:false, status, message}
|
|
124
|
-
async function authorize(
|
|
125
|
-
|
|
126
|
-
if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'policy' };
|
|
122
|
+
async function authorize(policy, host, port, upstream) {
|
|
123
|
+
if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'domain' };
|
|
127
124
|
const isIp = net.isIP(host) !== 0;
|
|
128
125
|
const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
|
|
129
126
|
if (isIp) {
|
|
@@ -153,11 +150,21 @@ function createEgressProxy(options) {
|
|
|
153
150
|
return { ok: true, ip: usable[0] };
|
|
154
151
|
}
|
|
155
152
|
|
|
153
|
+
// Proxy-Status(RFC 9209)只带通用错误类型;拒绝一律 403,正文只有 Forbidden
|
|
154
|
+
function statusHeader(message) {
|
|
155
|
+
return message === 'address' ? 'proxy; error=destination_ip_prohibited' : 'proxy; error=http_request_denied';
|
|
156
|
+
}
|
|
157
|
+
|
|
156
158
|
function reject(res, status, message) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
+
const body = { 400: 'Bad Request', 403: 'Forbidden', 429: 'Too Many Requests' }[status] || 'Bad Gateway';
|
|
160
|
+
const headers = { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body), Connection: 'close' };
|
|
161
|
+
if (status === 403) headers['Proxy-Status'] = statusHeader(message);
|
|
159
162
|
res.writeHead(status, headers);
|
|
160
|
-
res.end(
|
|
163
|
+
res.end(body);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function deny(client, host, port, reason) {
|
|
167
|
+
try { onDenied({ id: client ? client.id : '', host, port, reason }); } catch (e) { /* 记录失败不影响拒绝 */ }
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
function acquire(id) {
|
|
@@ -172,8 +179,8 @@ function createEgressProxy(options) {
|
|
|
172
179
|
if (n <= 0) tunnels.delete(id); else tunnels.set(id, n);
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
function rawReject(socket, status, text) {
|
|
176
|
-
const extra = status ===
|
|
182
|
+
function rawReject(socket, status, text, message) {
|
|
183
|
+
const extra = status === 403 ? `Proxy-Status: ${statusHeader(message)}\r\n` : '';
|
|
177
184
|
socket.end(`HTTP/1.1 ${status} ${text}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
178
185
|
}
|
|
179
186
|
|
|
@@ -186,12 +193,17 @@ function createEgressProxy(options) {
|
|
|
186
193
|
async function onConnect(req, clientSocket, head) {
|
|
187
194
|
track(clientSocket);
|
|
188
195
|
clientSocket.on('error', () => {});
|
|
189
|
-
const
|
|
190
|
-
if (!id) return rawReject(clientSocket, 407, 'Proxy Authentication Required');
|
|
196
|
+
const client = identify(clientSocket);
|
|
191
197
|
const target = parseTarget(String(req.url || ''));
|
|
198
|
+
if (!client) return rawReject(clientSocket, 403, 'Forbidden', 'domain');
|
|
192
199
|
if (!target) return rawReject(clientSocket, 400, 'Bad Request');
|
|
193
|
-
const
|
|
194
|
-
|
|
200
|
+
const id = client.id;
|
|
201
|
+
const upstream = currentUpstream();
|
|
202
|
+
const verdict = await authorize(client.policy, target.host, target.port, upstream);
|
|
203
|
+
if (!verdict.ok) {
|
|
204
|
+
if (verdict.status === 403) deny(client, target.host, target.port, verdict.message);
|
|
205
|
+
return rawReject(clientSocket, verdict.status, verdict.status === 403 ? 'Forbidden' : 'Bad Gateway', verdict.message);
|
|
206
|
+
}
|
|
195
207
|
if (!acquire(id)) return rawReject(clientSocket, 429, 'Too Many Requests');
|
|
196
208
|
let released = false;
|
|
197
209
|
const done = () => { if (!released) { released = true; release(id); } };
|
|
@@ -239,17 +251,22 @@ function createEgressProxy(options) {
|
|
|
239
251
|
|
|
240
252
|
// 普通 HTTP 代理(绝对 URI 请求)
|
|
241
253
|
async function onRequest(req, res) {
|
|
242
|
-
const
|
|
243
|
-
if (!
|
|
254
|
+
const client = identify(req.socket);
|
|
255
|
+
if (!client) return reject(res, 403, 'domain');
|
|
256
|
+
const id = client.id;
|
|
244
257
|
let url;
|
|
245
|
-
try { url = new URL(req.url); } catch (e) { return reject(res, 400, '
|
|
246
|
-
if (url.protocol !== 'http:') return reject(res, 400, '
|
|
258
|
+
try { url = new URL(req.url); } catch (e) { return reject(res, 400, 'bad request'); }
|
|
259
|
+
if (url.protocol !== 'http:') return reject(res, 400, 'bad request');
|
|
247
260
|
const port = Number(url.port) || 80;
|
|
248
261
|
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
if (!
|
|
252
|
-
|
|
262
|
+
const upstream = currentUpstream();
|
|
263
|
+
const verdict = await authorize(client.policy, host, port, upstream);
|
|
264
|
+
if (!verdict.ok) {
|
|
265
|
+
if (verdict.status === 403) deny(client, host, port, verdict.message);
|
|
266
|
+
return reject(res, verdict.status, verdict.message);
|
|
267
|
+
}
|
|
268
|
+
if (!acquire(id)) return reject(res, 429, 'limit');
|
|
269
|
+
const headers = { ...req.headers, host: url.host }; // 放行判断看的是 URL 里的主机,Host 头必须与它一致(RFC 9112)
|
|
253
270
|
delete headers['proxy-authorization'];
|
|
254
271
|
delete headers['proxy-connection'];
|
|
255
272
|
const outbound = upstream
|
|
@@ -263,14 +280,14 @@ function createEgressProxy(options) {
|
|
|
263
280
|
proxyRes.pipe(res);
|
|
264
281
|
});
|
|
265
282
|
proxyReq.on('timeout', () => proxyReq.destroy());
|
|
266
|
-
proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, '
|
|
283
|
+
proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, 'upstream'); else res.destroy(); });
|
|
267
284
|
res.on('close', () => { done(); proxyReq.destroy(); });
|
|
268
285
|
req.pipe(proxyReq);
|
|
269
286
|
}
|
|
270
287
|
|
|
271
288
|
function start(listen) {
|
|
272
289
|
return new Promise((resolve, fail) => {
|
|
273
|
-
server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, '
|
|
290
|
+
server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, 'upstream'); } catch (e) { /* 连接已断 */ } }); });
|
|
274
291
|
server.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });
|
|
275
292
|
server.headersTimeout = 20000;
|
|
276
293
|
server.on('clientError', (err, socket) => socket.destroy());
|
|
@@ -295,4 +312,4 @@ function createEgressProxy(options) {
|
|
|
295
312
|
return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
|
|
296
313
|
}
|
|
297
314
|
|
|
298
|
-
module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers,
|
|
315
|
+
module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseTarget };
|