@xcanwin/manyoyo 8.5.1 → 8.6.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 +4 -4
- package/lib/container-network.js +100 -48
- package/lib/container-run.js +1 -1
- package/lib/egress-proxy.js +146 -64
- package/lib/egress-sidecar-main.js +23 -5
- package/lib/egress-sidecar.js +22 -12
- package/lib/network-endpoints.js +18 -19
- package/lib/network-policy.js +296 -77
- package/lib/web/AGENTS.md +1 -1
- package/lib/web/container-manage.js +68 -30
- package/lib/web/index.html +80 -81
- package/lib/web/server.js +12 -18
- package/manyoyo.example.json +9 -6
- package/package.json +1 -1
package/bin/manyoyo.js
CHANGED
|
@@ -49,7 +49,7 @@ const { applyAptMirror } = require('../lib/mirrors');
|
|
|
49
49
|
const containerState = require('../lib/container-state');
|
|
50
50
|
const { buildExecArgs, resolveContainerId } = require('../lib/container-exec');
|
|
51
51
|
const { createNetworkManager, NETWORK_NAME } = require('../lib/container-network');
|
|
52
|
-
const { normalizePolicy } = require('../lib/network-policy');
|
|
52
|
+
const { normalizePolicy, isUnrestricted: isUnrestrictedPolicy } = require('../lib/network-policy');
|
|
53
53
|
const { withEnvEndpoints } = require('../lib/network-endpoints');
|
|
54
54
|
const { resolveManageOptions } = require('../lib/container-manage-options');
|
|
55
55
|
const {
|
|
@@ -354,7 +354,7 @@ function installServeProcessDiagnostics(logger) {
|
|
|
354
354
|
* @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
|
|
355
355
|
* @property {string} [autostart] - 容器每次启动时由容器内 init 执行的 bash 脚本(新建容器时写入状态目录)
|
|
356
356
|
* @property {boolean} [autostartOnServe] - serve 启动时自动拉起此容器
|
|
357
|
-
* @property {object} [network] - 网络策略(preset /
|
|
357
|
+
* @property {object} [network] - 网络策略(preset / outbound / inbound / expose,见 lib/network-policy.js),不写为默认收紧
|
|
358
358
|
* @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
|
|
359
359
|
* @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
|
|
360
360
|
* @property {number} [cacheTTL] - 缓存过期天数
|
|
@@ -2141,8 +2141,8 @@ async function createNewContainer(runtime) {
|
|
|
2141
2141
|
|
|
2142
2142
|
await ensureRunImage(runtime);
|
|
2143
2143
|
|
|
2144
|
-
const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
|
|
2145
2144
|
const networkManager = createCliNetworkManager(runtime);
|
|
2145
|
+
const networkPolicy = await withEnvEndpoints(await networkManager.resolveRefs(normalizePolicy(runtime.network)), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
|
|
2146
2146
|
if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(runtime.containerExtraArgs || [])])) {
|
|
2147
2147
|
try {
|
|
2148
2148
|
await networkManager.ensureBridgeNetwork();
|
|
@@ -2156,7 +2156,7 @@ async function createNewContainer(runtime) {
|
|
|
2156
2156
|
envFiles: runtime.envFiles,
|
|
2157
2157
|
autostart: runtime.autostart,
|
|
2158
2158
|
network: networkPolicy,
|
|
2159
|
-
netRequired: networkPolicy
|
|
2159
|
+
netRequired: !isUnrestrictedPolicy(networkPolicy),
|
|
2160
2160
|
meta: { name: runtime.containerName }
|
|
2161
2161
|
});
|
|
2162
2162
|
|
package/lib/container-network.js
CHANGED
|
@@ -98,6 +98,11 @@ function parseUrlHostPort(value) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// 指向宿主机的代理(host.*.internal / loopback)要放行到容器里看到的宿主机 IP,而不是在宿主机上解析
|
|
102
|
+
function isHostTarget(host) {
|
|
103
|
+
return HOST_NAMES.includes(host) || host === 'localhost' || host === '::1' || /^127\./.test(host);
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
function uniqueBy(items, keyFn) {
|
|
102
107
|
const seen = new Set();
|
|
103
108
|
return items.filter(item => {
|
|
@@ -148,7 +153,7 @@ function createNetworkManager(options) {
|
|
|
148
153
|
});
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
//
|
|
156
|
+
// 域名规则的过滤代理:manyoyo 网络里的 sidecar 容器,按来源 IP 认容器(见 egress-sidecar.js)
|
|
152
157
|
const sidecar = createSidecarManager({ run, command, homeDir, imageRef: getImageRef, networkName: NETWORK_NAME, ensureNetwork: ensureBridgeNetwork, getUpstream });
|
|
153
158
|
|
|
154
159
|
// buildExecArgs 只需要同步 inspect 取 id;这里 withEnv:false 不会用到
|
|
@@ -180,6 +185,13 @@ function createNetworkManager(options) {
|
|
|
180
185
|
})).filter(item => state.isValidId(item.id));
|
|
181
186
|
}
|
|
182
187
|
|
|
188
|
+
// 把策略里按名称写的 @container:<名称> 换成 id;名称找不到抛错
|
|
189
|
+
async function resolveRefs(policy) {
|
|
190
|
+
if (!policyLib.hasNameRefs(policy)) return policy;
|
|
191
|
+
const byName = new Map((await listManaged()).map(item => [item.name, item.id]));
|
|
192
|
+
return policyLib.resolveContainerRefs(policy, name => byName.get(name) || '');
|
|
193
|
+
}
|
|
194
|
+
|
|
183
195
|
function loadPolicy(id) {
|
|
184
196
|
const raw = state.readNetworkRaw(homeDir, id);
|
|
185
197
|
return policyLib.normalizePolicy(raw || undefined);
|
|
@@ -228,6 +240,29 @@ function createNetworkManager(options) {
|
|
|
228
240
|
return await run(args, { timeout: 30000 });
|
|
229
241
|
}
|
|
230
242
|
|
|
243
|
+
// 容器间:别的容器入站规则里允许了本容器(来源写 @containers 或 @container:<本容器>),本容器才能出去到它(派生行)。
|
|
244
|
+
// 来源写 @any 不派生:别的容器开放自己,不能顺带放宽本容器的出站限制
|
|
245
|
+
function computeDerived(self, all) {
|
|
246
|
+
const derived = [];
|
|
247
|
+
for (const other of all) {
|
|
248
|
+
if (other.id === self.id || !other.running || !state.stateExists(homeDir, other.id)) continue;
|
|
249
|
+
let otherPolicy;
|
|
250
|
+
try { otherPolicy = loadPolicy(other.id); } catch (e) { continue; }
|
|
251
|
+
const b = safeBridge(other.info);
|
|
252
|
+
if (!b) continue;
|
|
253
|
+
otherPolicy.inbound.filter(rule => rule.enabled && rule.action === 'allow' && (rule.source === '@containers' || rule.source === `@container:${self.id}`)).forEach(rule => {
|
|
254
|
+
derived.push({ id: other.id, name: other.name, ip: b.ip, ports: rule.ports, proto: rule.proto });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return derived;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function derivedOf(name) {
|
|
261
|
+
const all = await listManaged();
|
|
262
|
+
const self = all.find(item => item.name === name);
|
|
263
|
+
return self ? computeDerived(self, all) : [];
|
|
264
|
+
}
|
|
265
|
+
|
|
231
266
|
// 下发时现算:容器看到的宿主机 IP / DNS / 代理 / 镜像源 / peer 的当前 IP
|
|
232
267
|
async function resolveEndpoints(self, policy, all) {
|
|
233
268
|
const hostsText = await readRuntimeNetworkFacts(self.info);
|
|
@@ -239,10 +274,10 @@ function createNetworkManager(options) {
|
|
|
239
274
|
const allow = [];
|
|
240
275
|
const addHost = (ports, proto) => hostIps.forEach(ip => allow.push({ ip, ports: String(ports), proto }));
|
|
241
276
|
|
|
242
|
-
//
|
|
277
|
+
// 必需端点(@manyoyo):上游代理、过滤代理、Playwright、镜像源
|
|
243
278
|
const env = envMapOf(self.info);
|
|
244
279
|
const proxyTargets = [];
|
|
245
|
-
// allowlist
|
|
280
|
+
// allowlist 下容器只能经过滤代理出网,上游代理不能直连放行(否则绕过域名规则)
|
|
246
281
|
if (policy.preset !== 'allowlist') {
|
|
247
282
|
Object.keys(env).filter(key => PROXY_ENV_KEYS.has(key)).forEach(key => {
|
|
248
283
|
const parsed = env[key] && parseUrlHostPort(env[key]);
|
|
@@ -254,15 +289,20 @@ function createNetworkManager(options) {
|
|
|
254
289
|
if (parsed) proxyTargets.push(parsed);
|
|
255
290
|
});
|
|
256
291
|
for (const target of uniqueBy(proxyTargets, t => `${t.host}:${t.port}`)) {
|
|
292
|
+
if (isHostTarget(target.host)) {
|
|
293
|
+
addHost(target.port, 'tcp');
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
257
296
|
for (const ip of await resolveHostToIps(target.host)) allow.push({ ip, ports: String(target.port), proto: 'tcp' });
|
|
258
297
|
}
|
|
259
|
-
// 过滤代理(sidecar
|
|
298
|
+
// 过滤代理(sidecar 容器):有启用的域名规则时,容器的 HTTP(S) 经它出网,只放行到它的 IP:端口;
|
|
260
299
|
// 容器发往它的包来源必须是自己的 IP(第二道保险:识别身份靠的就是来源 IP)
|
|
261
300
|
let managedEnv = [];
|
|
262
301
|
const antiSpoof = [];
|
|
263
|
-
|
|
302
|
+
const proxied = policyLib.usesProxy(policy);
|
|
303
|
+
if (proxied) {
|
|
264
304
|
const selfBridge = safeBridge(self.info);
|
|
265
|
-
if (!selfBridge) throw new Error('
|
|
305
|
+
if (!selfBridge) throw new Error('域名规则需要容器在 manyoyo 网络里,这个容器的网络不支持');
|
|
266
306
|
const proxy = await sidecar.ensure();
|
|
267
307
|
allow.push({ ip: proxy.ip, ports: String(proxy.port), proto: 'tcp' });
|
|
268
308
|
antiSpoof.push({ daddr: proxy.ip, saddr: selfBridge.ip });
|
|
@@ -270,50 +310,55 @@ function createNetworkManager(options) {
|
|
|
270
310
|
managedEnv = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].map(key => `${key}=${url}`);
|
|
271
311
|
}
|
|
272
312
|
playwrightHostPorts().forEach(port => addHost(port, 'tcp'));
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
policy.peers.inbound.forEach(entry => {
|
|
279
|
-
const peer = byId.get(entry.from);
|
|
280
|
-
const b = peer && peer.running ? safeBridge(peer.info) : null;
|
|
281
|
-
if (b) peerIn.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
|
|
313
|
+
|
|
314
|
+
const containers = {};
|
|
315
|
+
all.forEach(item => {
|
|
316
|
+
const b = item.running ? safeBridge(item.info) : null;
|
|
317
|
+
if (b) containers[item.id] = b.ip;
|
|
282
318
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const b = safeBridge(other.info);
|
|
288
|
-
if (!b) continue;
|
|
289
|
-
otherPolicy.peers.inbound.filter(entry => entry.from === self.id).forEach(entry => {
|
|
290
|
-
allow.push({ ip: b.ip, ports: entry.ports, proto: entry.proto });
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
return {
|
|
319
|
+
const derived = computeDerived(self, all);
|
|
320
|
+
derived.forEach(item => allow.push({ ip: item.ip, ports: item.ports, proto: item.proto }));
|
|
321
|
+
const bridge = bridgeOf(self.info);
|
|
322
|
+
const endpoints = {
|
|
294
323
|
managedEnv,
|
|
295
324
|
antiSpoof,
|
|
296
325
|
dns: dnsServers,
|
|
297
326
|
allow: uniqueBy(allow, a => `${a.ip}|${a.ports}|${a.proto}`),
|
|
298
|
-
|
|
299
|
-
|
|
327
|
+
derived: uniqueBy(derived, d => `${d.id}|${d.ports}|${d.proto}`),
|
|
328
|
+
hostIps,
|
|
329
|
+
containers,
|
|
330
|
+
bridge: bridge ? { subnet: bridge.subnet, gateway: bridge.gateway } : null
|
|
300
331
|
};
|
|
332
|
+
endpoints.proxyPolicy = proxied ? policyLib.compileProxyPolicy(policy, endpoints) : null;
|
|
333
|
+
return endpoints;
|
|
301
334
|
}
|
|
302
335
|
|
|
303
336
|
function safeBridge(info) {
|
|
304
337
|
try { return bridgeOf(info); } catch (e) { return null; }
|
|
305
338
|
}
|
|
306
339
|
|
|
307
|
-
// 过滤代理的「来源 IP → 容器 id +
|
|
340
|
+
// 过滤代理的「来源 IP → 容器 id + 编译好的规则」:所有运行中、属于本 HOME、出站有域名规则的容器。
|
|
341
|
+
// 规则在容器自己下发时编译并缓存;缓存没有(serve 刚重启、CLI 新进程)就沿用 clients.json 里同 id 同 IP 的旧项
|
|
342
|
+
const clientCache = new Map();
|
|
343
|
+
function clientEntryOf(item, previous) {
|
|
344
|
+
const bridge = safeBridge(item.info);
|
|
345
|
+
if (!bridge) return null;
|
|
346
|
+
const cached = clientCache.get(item.id);
|
|
347
|
+
if (cached && cached.ip === bridge.ip) return { ip: bridge.ip, entry: cached.entry };
|
|
348
|
+
const old = previous[bridge.ip];
|
|
349
|
+
return old && old.id === item.id ? { ip: bridge.ip, entry: old } : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
308
352
|
function syncEgressClients(all) {
|
|
353
|
+
const previous = sidecar.readClients();
|
|
309
354
|
const clients = {};
|
|
310
355
|
for (const item of all) {
|
|
311
356
|
if (!item.running || !state.stateExists(homeDir, item.id)) continue;
|
|
312
357
|
let policy;
|
|
313
358
|
try { policy = loadPolicy(item.id); } catch (e) { continue; }
|
|
314
|
-
if (policy
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
359
|
+
if (!policyLib.usesProxy(policy)) continue;
|
|
360
|
+
const found = clientEntryOf(item, previous);
|
|
361
|
+
if (found) clients[found.ip] = found.entry;
|
|
317
362
|
}
|
|
318
363
|
sidecar.writeClients(clients);
|
|
319
364
|
}
|
|
@@ -366,10 +411,13 @@ function createNetworkManager(options) {
|
|
|
366
411
|
state.writeNetStatus(homeDir, self.id, status);
|
|
367
412
|
return { ...status, id: self.id };
|
|
368
413
|
}
|
|
369
|
-
state.setNetRequired(homeDir, self.id, policy
|
|
414
|
+
state.setNetRequired(homeDir, self.id, !policyLib.isUnrestricted(policy));
|
|
370
415
|
const endpoints = await resolveEndpoints(self, policy, all);
|
|
371
416
|
state.syncFilesEnv(homeDir, self.id); // 环境变量文件的最新快照(供自启动脚本用)
|
|
372
417
|
state.writeManagedEnv(homeDir, self.id, endpoints.managedEnv);
|
|
418
|
+
const selfBridge = safeBridge(self.info);
|
|
419
|
+
if (endpoints.proxyPolicy && selfBridge) clientCache.set(self.id, { ip: selfBridge.ip, entry: { id: self.id, ...endpoints.proxyPolicy } });
|
|
420
|
+
else clientCache.delete(self.id);
|
|
373
421
|
syncEgressClients(all); // 先让代理认得这个容器,再放行门闩
|
|
374
422
|
await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints));
|
|
375
423
|
await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
|
|
@@ -378,7 +426,7 @@ function createNetworkManager(options) {
|
|
|
378
426
|
status: 'applied',
|
|
379
427
|
startedAt,
|
|
380
428
|
policyHash: policyHashOf(policy),
|
|
381
|
-
warning: privileged && policy
|
|
429
|
+
warning: privileged && !policyLib.isUnrestricted(policy) ? '特权容器(dind / sock 模式)里的进程自带 NET_ADMIN,可以改动这些规则,网络限制没有约束力' : '',
|
|
382
430
|
at: new Date().toISOString()
|
|
383
431
|
};
|
|
384
432
|
state.writeNetStatus(homeDir, self.id, status);
|
|
@@ -386,7 +434,7 @@ function createNetworkManager(options) {
|
|
|
386
434
|
} catch (e) {
|
|
387
435
|
const message = `网络规则下发失败: ${e.message}`;
|
|
388
436
|
try { state.writeNetStatus(homeDir, self.id, { status: 'error', message, startedAt, at: new Date().toISOString() }); } catch (err) { /* 状态目录可能已被删 */ }
|
|
389
|
-
//
|
|
437
|
+
// 旧规则可能还在(比如不限制 → 收紧失败):清掉门闩,让 manyoyo 的 exec 与自启动都按“未下发”处理
|
|
390
438
|
await clearGate(name);
|
|
391
439
|
const error = new Error(message);
|
|
392
440
|
error.cause = e;
|
|
@@ -420,7 +468,7 @@ function createNetworkManager(options) {
|
|
|
420
468
|
} catch (e) {
|
|
421
469
|
policy = null; // 策略文件损坏:按收紧处理,下发时会报错
|
|
422
470
|
}
|
|
423
|
-
if (policy && policy
|
|
471
|
+
if (policy && policyLib.isUnrestricted(policy)) return { status: 'unrestricted' };
|
|
424
472
|
// 判断依据在宿主机侧(net-status.json:同一次启动 + 同一份策略 + 下发成功),不依赖容器里的任何文件或命令
|
|
425
473
|
const status = state.readNetStatus(homeDir, id);
|
|
426
474
|
const startedAt = String((info.State && info.State.StartedAt) || '');
|
|
@@ -428,20 +476,25 @@ function createNetworkManager(options) {
|
|
|
428
476
|
if (sameRun && status.status === 'unsupported') return { status: 'unsupported' };
|
|
429
477
|
if (policy && sameRun && status.status === 'applied' && status.policyHash === policyHashOf(policy)) {
|
|
430
478
|
// 规则还在,但过滤代理可能意外退了(容器出网此时是失败即关闭):顺手拉起,失败不拦 exec
|
|
431
|
-
if (
|
|
479
|
+
if (policyLib.usesProxy(policy)) {
|
|
432
480
|
try {
|
|
433
481
|
if (!await sidecar.isHealthy()) {
|
|
434
482
|
await sidecar.ensure();
|
|
435
483
|
syncEgressClients(await listManaged());
|
|
436
484
|
}
|
|
437
|
-
} catch (e) { warn(
|
|
485
|
+
} catch (e) { warn(`域名规则代理恢复失败: ${e.message}`); }
|
|
486
|
+
// 代理不认识这个容器(规则没缓存、clients.json 里也没有):重新下发一次
|
|
487
|
+
if (!clientEntryOf({ id, info }, sidecar.readClients())) return await apply(name, { expectId: id });
|
|
438
488
|
}
|
|
439
489
|
return { status: 'applied' };
|
|
440
490
|
}
|
|
441
491
|
return await apply(name, { expectId: id });
|
|
442
492
|
}
|
|
443
493
|
|
|
444
|
-
// 与 `name`
|
|
494
|
+
// 与 `name` 有容器间规则关系的其他运行中容器(本容器重启后 IP 变化时要重算它们)
|
|
495
|
+
const rulesOf = policy => [...policy.outbound, ...policy.inbound].filter(rule => rule.enabled);
|
|
496
|
+
const refersTo = (policy, id) => rulesOf(policy).some(rule => (rule.target || rule.source) === `@container:${id}`);
|
|
497
|
+
const opensToContainers = policy => policy.inbound.some(rule => rule.enabled && rule.action === 'allow' && rule.source === '@containers');
|
|
445
498
|
async function relatedContainers(name) {
|
|
446
499
|
const all = await listManaged();
|
|
447
500
|
const self = all.find(item => item.name === name);
|
|
@@ -452,7 +505,7 @@ function createNetworkManager(options) {
|
|
|
452
505
|
let p;
|
|
453
506
|
let mine;
|
|
454
507
|
try { p = loadPolicy(other.id); mine = loadPolicy(self.id); } catch (e) { continue; }
|
|
455
|
-
if (p
|
|
508
|
+
if (refersTo(p, self.id) || refersTo(mine, other.id) || opensToContainers(p) || opensToContainers(mine)) related.push(other.name);
|
|
456
509
|
}
|
|
457
510
|
return related;
|
|
458
511
|
}
|
|
@@ -468,22 +521,20 @@ function createNetworkManager(options) {
|
|
|
468
521
|
}
|
|
469
522
|
}
|
|
470
523
|
// 没有容器需要重新下发规则时,过滤代理也要在(运行时重启后它同样是停着的)
|
|
471
|
-
await healSidecar().catch(e => warn(
|
|
524
|
+
await healSidecar().catch(e => warn(`域名规则代理恢复失败: ${e.message}`));
|
|
472
525
|
}
|
|
473
526
|
|
|
474
|
-
|
|
475
|
-
// 事件到来就重新下发,并重算与它有 peers 关系的容器(它的 IP 可能变了)。
|
|
476
|
-
async function needsSidecar(all) {
|
|
527
|
+
async function needsSidecar(all) {
|
|
477
528
|
return all.some(item => {
|
|
478
529
|
if (!state.stateExists(homeDir, item.id)) return false;
|
|
479
530
|
try {
|
|
480
531
|
const policy = loadPolicy(item.id);
|
|
481
|
-
return
|
|
532
|
+
return policyLib.usesProxy(policy);
|
|
482
533
|
} catch (e) { return false; }
|
|
483
534
|
});
|
|
484
535
|
}
|
|
485
536
|
|
|
486
|
-
// 过滤代理容器自己退出 /
|
|
537
|
+
// 过滤代理容器自己退出 / 被删:有用到它的容器就拉起来,并重写映射
|
|
487
538
|
let lastHealAt = 0;
|
|
488
539
|
let healTimer = null;
|
|
489
540
|
async function healSidecar() {
|
|
@@ -494,7 +545,7 @@ function createNetworkManager(options) {
|
|
|
494
545
|
if (!healTimer) {
|
|
495
546
|
healTimer = setTimeout(() => {
|
|
496
547
|
healTimer = null;
|
|
497
|
-
healSidecar().catch(e => warn(
|
|
548
|
+
healSidecar().catch(e => warn(`域名规则代理恢复失败: ${e.message}`));
|
|
498
549
|
}, wait + 50);
|
|
499
550
|
if (healTimer.unref) healTimer.unref();
|
|
500
551
|
}
|
|
@@ -601,7 +652,7 @@ function createNetworkManager(options) {
|
|
|
601
652
|
};
|
|
602
653
|
}
|
|
603
654
|
|
|
604
|
-
return { NETWORK_NAME, sidecar, run, listManaged, loadPolicy, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
|
|
655
|
+
return { NETWORK_NAME, sidecar, run, listManaged, loadPolicy, resolveRefs, derivedOf, ensureBridgeNetwork, apply, ensureReady, relatedContainers, resolveEndpoints, reconcileAll, watchStarts };
|
|
605
656
|
}
|
|
606
657
|
|
|
607
658
|
module.exports = {
|
|
@@ -611,5 +662,6 @@ module.exports = {
|
|
|
611
662
|
bridgeOf,
|
|
612
663
|
parseHostsAndResolv,
|
|
613
664
|
parseUrlHostPort,
|
|
665
|
+
isHostTarget,
|
|
614
666
|
createNetworkManager
|
|
615
667
|
};
|
package/lib/container-run.js
CHANGED
|
@@ -27,7 +27,7 @@ function buildContainerRunArgs(options) {
|
|
|
27
27
|
'--name', options.containerName,
|
|
28
28
|
'--entrypoint', `${SYS_MOUNT}/init.sh`,
|
|
29
29
|
// docker 默认带 NET_RAW(podman 默认不带):有它的容器能发原始包 / 伪造 ARP,冒充别的容器的来源 IP,
|
|
30
|
-
//
|
|
30
|
+
// 域名规则的过滤代理就是按来源 IP 认容器的,所以统一去掉
|
|
31
31
|
'--cap-drop', 'NET_RAW',
|
|
32
32
|
...(options.contModeArgs || []),
|
|
33
33
|
...(options.containerExtraArgs || []),
|
package/lib/egress-proxy.js
CHANGED
|
@@ -3,27 +3,43 @@
|
|
|
3
3
|
const dns = require('dns').promises;
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const net = require('net');
|
|
6
|
-
const {
|
|
6
|
+
const { domainMatches, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// 认不出的来源一律 403
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// 域名规则的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
|
|
9
|
+
// 有启用域名规则的容器,HTTP(S) 都走这个代理,防火墙只放行到它;这里按连接的来源 IP 认出是哪个容器(getClient),
|
|
10
|
+
// 认不出的来源一律 403;然后按该容器编译好的有序规则(域名行 + 地址行 + 模式默认行)从上往下找第一条命中的。
|
|
11
|
+
// 地址行按“任一解析结果”匹配;通配域名命中后,解析结果若落在 @host / @private / @metadata,仍要有一条允许该地址的规则。
|
|
12
|
+
// 环回 / 未指定 / 组播地址永远拒绝(防借代理打 sidecar 自己)。
|
|
13
13
|
// 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
|
|
14
14
|
const CONNECT_TIMEOUT_MS = 15000;
|
|
15
15
|
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
16
|
const MAX_TUNNELS_PER_CONTAINER = 256;
|
|
17
|
+
const LOOKUP_TIMEOUT_MS = 5000;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
function withTimeout(promise, ms) {
|
|
20
|
+
let timer;
|
|
21
|
+
return Promise.race([promise, new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); })]).finally(() => clearTimeout(timer));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function liveOnly(ips) {
|
|
25
|
+
return ips.filter(ip => !checkList(floor, ip));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function blockListOf(cidrs) {
|
|
29
|
+
const list = new net.BlockList();
|
|
30
|
+
(cidrs || []).forEach(cidr => {
|
|
31
|
+
const [addr, prefix] = String(cidr).split('/');
|
|
32
|
+
const family = net.isIP(addr);
|
|
33
|
+
if (!family) return;
|
|
34
|
+
const type = family === 4 ? 'ipv4' : 'ipv6';
|
|
35
|
+
if (prefix === undefined) list.addAddress(addr, type);
|
|
36
|
+
else list.addSubnet(addr, Number(prefix), type);
|
|
37
|
+
});
|
|
38
|
+
return list;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const blocked = blockListOf([...PRIVATE_V4, ...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128', '127.0.0.0/8', '255.255.255.255/32']);
|
|
42
|
+
const floor = blockListOf(['127.0.0.0/8', '0.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32', '::1/128', '::/128', 'ff00::/8']);
|
|
27
43
|
|
|
28
44
|
// ::ffff:a.b.c.d 与 NAT64(64:ff9b::/96)里嵌的 IPv4 要按 IPv4 判断
|
|
29
45
|
function embeddedV4(ip) {
|
|
@@ -39,29 +55,102 @@ function embeddedV4(ip) {
|
|
|
39
55
|
return '';
|
|
40
56
|
}
|
|
41
57
|
|
|
58
|
+
function checkList(list, ip) {
|
|
59
|
+
const addr = embeddedV4(ip) || ip;
|
|
60
|
+
const family = net.isIP(addr);
|
|
61
|
+
return family ? list.check(addr, family === 4 ? 'ipv4' : 'ipv6') : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
function isRestrictedAddress(ip) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const family = net.isIP(ip);
|
|
46
|
-
if (!family) return true;
|
|
47
|
-
return blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6');
|
|
65
|
+
const hit = checkList(blocked, ip);
|
|
66
|
+
return hit === null ? true : hit;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
57
|
-
if (!ok) return false;
|
|
69
|
+
const setCache = new WeakMap();
|
|
70
|
+
function compiledSet(set) {
|
|
71
|
+
let compiled = setCache.get(set);
|
|
72
|
+
if (!compiled) {
|
|
73
|
+
compiled = { inc: blockListOf(set.inc), exc: blockListOf(set.exc) };
|
|
74
|
+
setCache.set(set, compiled);
|
|
58
75
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
return compiled;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 地址集合 {any, inc, exc} 是否包含 ip。 */
|
|
80
|
+
function setCovers(set, ip) {
|
|
81
|
+
if (!set) return false;
|
|
82
|
+
const compiled = compiledSet(set);
|
|
83
|
+
if (!set.any && !checkList(compiled.inc, ip)) return false;
|
|
84
|
+
return !checkList(compiled.exc, ip);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const listCache = new WeakMap();
|
|
88
|
+
function listCovers(cidrs, ip) {
|
|
89
|
+
let list = listCache.get(cidrs);
|
|
90
|
+
if (!list) {
|
|
91
|
+
list = blockListOf(cidrs);
|
|
92
|
+
listCache.set(cidrs, list);
|
|
93
|
+
}
|
|
94
|
+
return Boolean(checkList(list, ip));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function portsCover(ports, port) {
|
|
98
|
+
if (!ports) return true;
|
|
99
|
+
return String(ports).split(',').some(item => {
|
|
100
|
+
const [a, b] = item.split('-').map(Number);
|
|
101
|
+
return port >= a && port <= (b === undefined ? a : b);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 按容器编译好的有序规则判断一次连接。解析是惰性的:只有规则需要看地址时才查 DNS(被域名规则直接拒绝 / 放行的不用等 DNS)。
|
|
107
|
+
* @param {{rules: object[], default: string, sensitive?: string[]}} policy
|
|
108
|
+
* @param {string} host 目标主机(域名或 IP 字面量)
|
|
109
|
+
* @param {number} port
|
|
110
|
+
* @param {string[]|(() => Promise<string[]>)} ipsOrResolve 目标的解析结果,或惰性解析函数(目标本身是 IP 时就是它自己;解析不出来为空)
|
|
111
|
+
* @returns {Promise<{ok: boolean, reason?: string, usable?: string[]|null}>} usable 为 null 表示判断时没看地址,由调用方按解析结果挑
|
|
112
|
+
*/
|
|
113
|
+
async function decide(policy, host, port, ipsOrResolve) {
|
|
114
|
+
const isIp = net.isIP(host) !== 0;
|
|
115
|
+
const deny = reason => ({ ok: false, reason });
|
|
116
|
+
let live = null;
|
|
117
|
+
const liveIps = async () => {
|
|
118
|
+
if (live === null) {
|
|
119
|
+
const ips = Array.isArray(ipsOrResolve) ? ipsOrResolve : (isIp ? [host] : await ipsOrResolve());
|
|
120
|
+
live = { all: ips, ok: ips.filter(ip => !checkList(floor, ip)) };
|
|
121
|
+
}
|
|
122
|
+
return live;
|
|
123
|
+
};
|
|
124
|
+
const applies = rule => rule.proto !== 'udp' && portsCover(rule.ports, port);
|
|
125
|
+
const addressAllowed = ip => {
|
|
126
|
+
for (const rule of policy.rules) {
|
|
127
|
+
if (rule.domain || !applies(rule) || !setCovers(rule.addr, ip)) continue;
|
|
128
|
+
return rule.action === 'allow';
|
|
129
|
+
}
|
|
130
|
+
return policy.default === 'allow';
|
|
131
|
+
};
|
|
132
|
+
for (const rule of policy.rules) {
|
|
133
|
+
if (!applies(rule)) continue;
|
|
134
|
+
if (rule.domain) {
|
|
135
|
+
if (isIp || !domainMatches(rule.domain, host)) continue;
|
|
136
|
+
if (rule.action === 'deny') return deny('domain');
|
|
137
|
+
if (rule.domain.startsWith('*.')) {
|
|
138
|
+
const { all, ok } = await liveIps();
|
|
139
|
+
if (all.length && !ok.length) return deny('address');
|
|
140
|
+
const sensitive = policy.sensitive || [];
|
|
141
|
+
const usable = ok.filter(ip => !listCovers(sensitive, ip) || addressAllowed(ip));
|
|
142
|
+
if (ok.length && !usable.length) return deny('address');
|
|
143
|
+
return { ok: true, usable };
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, usable: null };
|
|
146
|
+
}
|
|
147
|
+
const { all, ok } = await liveIps();
|
|
148
|
+
if (all.length && !ok.length) return deny('address');
|
|
149
|
+
const hit = ok.filter(ip => setCovers(rule.addr, ip));
|
|
150
|
+
if (!hit.length) continue;
|
|
151
|
+
return rule.action === 'allow' ? { ok: true, usable: isIp ? ok : hit } : deny('address');
|
|
152
|
+
}
|
|
153
|
+
return policy.default === 'allow' ? { ok: true, usable: null } : deny('domain');
|
|
65
154
|
}
|
|
66
155
|
|
|
67
156
|
function parseTarget(text) {
|
|
@@ -74,14 +163,16 @@ function parseTarget(text) {
|
|
|
74
163
|
|
|
75
164
|
/**
|
|
76
165
|
* @param {object} options
|
|
77
|
-
* @param {(ip: string) => {id: string,
|
|
166
|
+
* @param {(ip: string) => {id: string, default: string, sensitive?: string[], rules: object[]}|null} options.getClient 按来源 IP 认容器(认不出返回 null)
|
|
78
167
|
* @param {(event: {id: string, host: string, port: number, reason: string}) => void} [options.onDenied] 拒绝记录
|
|
79
168
|
* @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
|
|
80
169
|
* @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
|
|
170
|
+
* @param {(ip: string) => string} [options.mapAddress] 测试替身:实际拨号的地址(单测里把虚构的公网 IP 映射到本机夹具)
|
|
81
171
|
*/
|
|
82
172
|
function createEgressProxy(options) {
|
|
83
173
|
const getClient = options.getClient;
|
|
84
174
|
const onDenied = options.onDenied || (() => {});
|
|
175
|
+
const mapAddress = options.mapAddress || (ip => ip);
|
|
85
176
|
const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
|
|
86
177
|
// upstream 可以是字符串或返回字符串的函数(sidecar 里按文件改动热更新);解析结果按字符串缓存
|
|
87
178
|
let parsedFor = null;
|
|
@@ -120,33 +211,24 @@ function createEgressProxy(options) {
|
|
|
120
211
|
|
|
121
212
|
// 返回 {ok:true, ip?} 或 {ok:false, status, message}
|
|
122
213
|
async function authorize(policy, host, port, upstream) {
|
|
123
|
-
if (!policy || policy.
|
|
214
|
+
if (!policy || !Array.isArray(policy.rules)) return { ok: false, status: 403, message: 'domain' };
|
|
124
215
|
const isIp = net.isIP(host) !== 0;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
let addresses;
|
|
143
|
-
try {
|
|
144
|
-
addresses = await lookup(host);
|
|
145
|
-
} catch (e) {
|
|
146
|
-
return { ok: false, status: 502, message: 'dns' };
|
|
147
|
-
}
|
|
148
|
-
const usable = addresses.filter(ip => !isRestrictedAddress(ip) || explicit(ip, port));
|
|
149
|
-
if (!usable.length) return { ok: false, status: 403, message: 'address' };
|
|
216
|
+
let addresses = null;
|
|
217
|
+
const resolveIps = async () => {
|
|
218
|
+
if (addresses === null) {
|
|
219
|
+
addresses = [];
|
|
220
|
+
try {
|
|
221
|
+
addresses = await withTimeout(lookup(host), LOOKUP_TIMEOUT_MS);
|
|
222
|
+
} catch (e) { /* 解析不出来:规则里的域名行仍然判断;经上游代理时交给上游解析 */ }
|
|
223
|
+
}
|
|
224
|
+
return addresses;
|
|
225
|
+
};
|
|
226
|
+
const verdict = await decide(policy, host, port, isIp ? [host] : resolveIps);
|
|
227
|
+
if (!verdict.ok) return { ok: false, status: 403, message: verdict.reason };
|
|
228
|
+
if (upstream) return { ok: true, ip: '' };
|
|
229
|
+
// 直接拨号:需要地址。规则没看过地址时,从解析结果里挑非环回的
|
|
230
|
+
const usable = verdict.usable || (await liveOnly(isIp ? [host] : await resolveIps()));
|
|
231
|
+
if (!usable.length) return isIp || (addresses && addresses.length) ? { ok: false, status: 403, message: 'address' } : { ok: false, status: 502, message: 'dns' };
|
|
150
232
|
return { ok: true, ip: usable[0] };
|
|
151
233
|
}
|
|
152
234
|
|
|
@@ -234,7 +316,7 @@ function createEgressProxy(options) {
|
|
|
234
316
|
upstreamSocket.on('data', onData);
|
|
235
317
|
});
|
|
236
318
|
} else {
|
|
237
|
-
upstreamSocket = track(net.connect({ host: verdict.ip, port: target.port }));
|
|
319
|
+
upstreamSocket = track(net.connect({ host: mapAddress(verdict.ip), port: target.port }));
|
|
238
320
|
upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
239
321
|
upstreamSocket.once('connect', () => {
|
|
240
322
|
upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
@@ -271,7 +353,7 @@ function createEgressProxy(options) {
|
|
|
271
353
|
delete headers['proxy-connection'];
|
|
272
354
|
const outbound = upstream
|
|
273
355
|
? { host: upstream.host, port: upstream.port, path: req.url, headers: { ...headers, ...(upstream.auth ? { 'proxy-authorization': upstream.auth } : {}) } }
|
|
274
|
-
: { host: verdict.ip, port, path: `${url.pathname}${url.search}`, headers };
|
|
356
|
+
: { host: mapAddress(verdict.ip), port, path: `${url.pathname}${url.search}`, headers };
|
|
275
357
|
const proxyReq = http.request({ ...outbound, method: req.method, timeout: CONNECT_TIMEOUT_MS });
|
|
276
358
|
let released = false;
|
|
277
359
|
const done = () => { if (!released) { released = true; release(id); } };
|
|
@@ -312,4 +394,4 @@ function createEgressProxy(options) {
|
|
|
312
394
|
return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
|
|
313
395
|
}
|
|
314
396
|
|
|
315
|
-
module.exports = { createEgressProxy, isRestrictedAddress,
|
|
397
|
+
module.exports = { createEgressProxy, isRestrictedAddress, decide, setCovers, parseTarget };
|