@xcanwin/manyoyo 8.5.1 → 8.6.2
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-exec.js +20 -1
- 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 +84 -30
- package/lib/web/index.html +81 -82
- package/lib/web/server.js +103 -25
- package/manyoyo.example.json +9 -6
- package/package.json +1 -1
package/lib/network-policy.js
CHANGED
|
@@ -6,15 +6,23 @@ const { isValidId } = require('./container-id');
|
|
|
6
6
|
// 容器网络策略:校验(唯一入口)+ 生成 nft 规则文本。
|
|
7
7
|
// nft 文本只由校验过的结构化数据生成:IP/CIDR 经 net.isIP + 前缀整数、端口是 1-65535 的整数、域名不进规则
|
|
8
8
|
// (域名只在过滤代理里匹配),所以任何用户字符串都拼不进规则。
|
|
9
|
-
const PRESETS = ['restricted', '
|
|
10
|
-
const PROTOS = ['tcp', 'udp'];
|
|
11
|
-
|
|
9
|
+
const PRESETS = ['restricted', 'allowlist', 'custom'];
|
|
10
|
+
const PROTOS = ['all', 'tcp', 'udp'];
|
|
11
|
+
// v1Outbound:旧版 deny / host / rules / domains 四个列表合起来的上限,迁移后不能因为超过 200 而读不出来
|
|
12
|
+
const LIMITS = { outbound: 200, inbound: 50, expose: 20, portItems: 32, v1: 200, v1Outbound: 450 };
|
|
12
13
|
const DOMAIN_RE = /^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
14
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
15
|
+
const OUTBOUND_VARS = ['@containers', '@host', '@private', '@public', '@metadata', '@any'];
|
|
16
|
+
const INBOUND_VARS = ['@containers', '@host', '@any'];
|
|
17
|
+
const ALWAYS_ALLOWED_VARS = ['@cont_local', '@manyoyo'];
|
|
13
18
|
|
|
14
19
|
// restricted / allowlist 默认禁止的私有与保留网段(公网放行)
|
|
15
20
|
const PRIVATE_V4 = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4'];
|
|
16
21
|
// 64:ff9b::/96(NAT64)里嵌的是 IPv4,可以映射到私有地址
|
|
17
22
|
const PRIVATE_V6 = ['fc00::/7', '64:ff9b::/96'];
|
|
23
|
+
// 云服务器元数据地址(AWS / GCP / Azure、阿里云、AWS IPv6)
|
|
24
|
+
const METADATA_V4 = ['169.254.169.254', '100.100.100.200'];
|
|
25
|
+
const METADATA_V6 = ['fd00:ec2::254'];
|
|
18
26
|
|
|
19
27
|
function fail(message) {
|
|
20
28
|
throw new Error(message);
|
|
@@ -47,8 +55,8 @@ function normalizePorts(input, { required = true, label = '端口' } = {}) {
|
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
function normalizeProto(input) {
|
|
50
|
-
const proto = String(input === undefined || input === null || input === '' ? '
|
|
51
|
-
if (!PROTOS.includes(proto)) fail(`协议只支持 tcp / udp: ${String(input).slice(0, 20)}`);
|
|
58
|
+
const proto = String(input === undefined || input === null || input === '' ? 'all' : input).toLowerCase();
|
|
59
|
+
if (!PROTOS.includes(proto)) fail(`协议只支持 all / tcp / udp: ${String(input).slice(0, 20)}`);
|
|
52
60
|
return proto;
|
|
53
61
|
}
|
|
54
62
|
|
|
@@ -92,54 +100,114 @@ function asObject(value, label) {
|
|
|
92
100
|
|
|
93
101
|
function defaultPolicy() {
|
|
94
102
|
return {
|
|
95
|
-
version:
|
|
103
|
+
version: 2,
|
|
96
104
|
preset: 'restricted',
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
peers: { inbound: [] },
|
|
100
|
-
deny: [],
|
|
105
|
+
outbound: [],
|
|
106
|
+
inbound: [],
|
|
101
107
|
expose: [],
|
|
102
108
|
autostartOnServe: false
|
|
103
109
|
};
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
// ---- 规则目标 ----
|
|
113
|
+
|
|
114
|
+
/** 目标 / 来源的类别:var(@ 变量)/ cidr / domain。 */
|
|
115
|
+
function targetKind(target) {
|
|
116
|
+
if (String(target).startsWith('@')) return 'var';
|
|
117
|
+
return net.isIP(String(target).split('/')[0]) ? 'cidr' : 'domain';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeTarget(input, direction) {
|
|
121
|
+
const label = direction === 'inbound' ? '来源' : '目标';
|
|
122
|
+
const text = String(input === undefined || input === null ? '' : input).trim();
|
|
123
|
+
if (!text) fail(`${label}不能为空`);
|
|
124
|
+
if (text.startsWith('@')) {
|
|
125
|
+
const [head, ...rest] = text.split(':');
|
|
126
|
+
const name = head.toLowerCase();
|
|
127
|
+
if (ALWAYS_ALLOWED_VARS.includes(name)) fail(`${name} 始终允许,不用写规则`);
|
|
128
|
+
if (name === '@container') {
|
|
129
|
+
const ref = rest.join(':');
|
|
130
|
+
if (!NAME_RE.test(ref) || ref.length > 128) fail(`@container: 后面要写容器名称: ${text.slice(0, 60)}`);
|
|
131
|
+
return `@container:${ref}`;
|
|
132
|
+
}
|
|
133
|
+
if (rest.length || !(direction === 'inbound' ? INBOUND_VARS : OUTBOUND_VARS).includes(name)) fail(`${label}不支持: ${text.slice(0, 60)}`);
|
|
134
|
+
return name;
|
|
135
|
+
}
|
|
136
|
+
if (net.isIP(text.split('/')[0])) return normalizeCidr(text, label).cidr;
|
|
137
|
+
if (direction === 'inbound') fail(`来源只支持 IP / 网段 / @ 变量: ${text.slice(0, 60)}`);
|
|
138
|
+
return normalizeDomain(text);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeRule(entry, direction, index) {
|
|
142
|
+
const where = `${direction}[${index}]`;
|
|
143
|
+
const r = asObject(entry, where);
|
|
144
|
+
if (r.action !== 'allow' && r.action !== 'deny') fail(`${where}.action 只支持 allow / deny`);
|
|
145
|
+
const field = direction === 'inbound' ? 'source' : 'target';
|
|
146
|
+
const target = normalizeTarget(r[field], direction);
|
|
147
|
+
const proto = normalizeProto(r.proto);
|
|
148
|
+
if (targetKind(target) === 'domain' && proto === 'udp') fail(`域名规则只支持 tcp: ${target}`);
|
|
149
|
+
return { action: r.action, [field]: target, ports: normalizePorts(r.ports, { required: false }), proto, enabled: r.enabled !== false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- v1 → v2(读取磁盘上的旧数据时一次性转换;不是兼容层,下次保存即写成 v2) ----
|
|
153
|
+
|
|
154
|
+
function isV1(src) {
|
|
155
|
+
return src.version === 1 || ['egress', 'host', 'peers', 'deny'].some(key => src[key] !== undefined);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function migrateV1(src) {
|
|
159
|
+
const out = { ...src, version: 2, outbound: [], inbound: [] };
|
|
160
|
+
const allowAny = { action: 'allow', ports: '', proto: 'all', enabled: true };
|
|
161
|
+
if (src.preset === 'open') {
|
|
162
|
+
out.preset = 'custom';
|
|
163
|
+
out.outbound = [{ ...allowAny, target: '@any' }];
|
|
164
|
+
out.inbound = [{ ...allowAny, source: '@any' }];
|
|
165
|
+
} else {
|
|
166
|
+
const egress = asObject(src.egress, 'egress');
|
|
167
|
+
asArray(src.deny, LIMITS.v1, 'deny').forEach(entry => {
|
|
168
|
+
out.outbound.push({ action: 'deny', target: asObject(entry, 'deny[]').cidr, ports: '', proto: 'all', enabled: true });
|
|
169
|
+
});
|
|
170
|
+
asArray(src.host, LIMITS.v1, 'host').forEach(entry => {
|
|
171
|
+
const e = asObject(entry, 'host[]');
|
|
172
|
+
out.outbound.push({ action: 'allow', target: '@host', ports: e.ports, proto: e.proto || 'tcp', enabled: true });
|
|
173
|
+
});
|
|
174
|
+
asArray(egress.rules, LIMITS.v1, 'egress.rules').forEach(entry => {
|
|
175
|
+
const e = asObject(entry, 'egress.rules[]');
|
|
176
|
+
out.outbound.push({ action: 'allow', target: e.cidr, ports: e.ports, proto: e.proto || 'tcp', enabled: true });
|
|
177
|
+
});
|
|
178
|
+
// 非白名单预设下旧的域名列表本来不生效,迁移成暂停行,免得老容器突然改走过滤代理
|
|
179
|
+
asArray(egress.domains, LIMITS.v1, 'egress.domains').forEach(domain => {
|
|
180
|
+
out.outbound.push({ action: 'allow', target: domain, ports: '', proto: 'all', enabled: src.preset === 'allowlist' });
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (src.preset !== 'open') {
|
|
184
|
+
asArray(asObject(src.peers, 'peers').inbound, LIMITS.v1, 'peers.inbound').forEach(entry => {
|
|
185
|
+
const e = asObject(entry, 'peers.inbound[]');
|
|
186
|
+
if (!isValidId(e.from)) fail('peers.inbound.from 必须是容器 id');
|
|
187
|
+
out.inbound.push({ action: 'allow', source: `@container:${e.from}`, ports: e.ports, proto: e.proto || 'tcp', enabled: true });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
delete out.egress;
|
|
191
|
+
delete out.host;
|
|
192
|
+
delete out.peers;
|
|
193
|
+
delete out.deny;
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
106
197
|
/**
|
|
107
|
-
* 校验并规范化策略;任何非法输入抛错(调用方返回 400
|
|
198
|
+
* 校验并规范化策略;任何非法输入抛错(调用方返回 400)。旧 v1 数据先转成 v2。
|
|
108
199
|
*/
|
|
109
200
|
function normalizePolicy(input) {
|
|
110
|
-
|
|
201
|
+
let src = asObject(input, '网络策略');
|
|
202
|
+
const migrated = isV1(src);
|
|
203
|
+
if (migrated) src = migrateV1(src);
|
|
111
204
|
const policy = defaultPolicy();
|
|
112
205
|
if (src.preset !== undefined) {
|
|
113
206
|
if (!PRESETS.includes(src.preset)) fail(`preset 只支持 ${PRESETS.join(' / ')}`);
|
|
114
207
|
policy.preset = src.preset;
|
|
115
208
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
asArray(egress.domains, LIMITS.domains, 'egress.domains').forEach(d => {
|
|
119
|
-
const domain = normalizeDomain(d);
|
|
120
|
-
if (!seenDomains.has(domain)) {
|
|
121
|
-
seenDomains.add(domain);
|
|
122
|
-
policy.egress.domains.push(domain);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
asArray(egress.rules, LIMITS.rules, 'egress.rules').forEach(rule => {
|
|
126
|
-
const r = asObject(rule, 'egress.rules[]');
|
|
127
|
-
const { cidr } = normalizeCidr(r.cidr, 'egress.rules.cidr');
|
|
128
|
-
policy.egress.rules.push({ cidr, ports: normalizePorts(r.ports, { required: false }), proto: normalizeProto(r.proto) });
|
|
129
|
-
});
|
|
130
|
-
asArray(src.host, LIMITS.host, 'host').forEach(entry => {
|
|
131
|
-
const e = asObject(entry, 'host[]');
|
|
132
|
-
policy.host.push({ ports: normalizePorts(e.ports, { label: '宿主机端口' }), proto: normalizeProto(e.proto) });
|
|
133
|
-
});
|
|
134
|
-
const peers = asObject(src.peers, 'peers');
|
|
135
|
-
asArray(peers.inbound, LIMITS.inbound, 'peers.inbound').forEach(entry => {
|
|
136
|
-
const e = asObject(entry, 'peers.inbound[]');
|
|
137
|
-
if (!isValidId(e.from)) fail('peers.inbound.from 必须是容器 id');
|
|
138
|
-
policy.peers.inbound.push({ from: e.from, ports: normalizePorts(e.ports, { label: '入站端口' }), proto: normalizeProto(e.proto) });
|
|
139
|
-
});
|
|
140
|
-
asArray(src.deny, LIMITS.deny, 'deny').forEach(entry => {
|
|
141
|
-
policy.deny.push({ cidr: normalizeCidr(asObject(entry, 'deny[]').cidr, 'deny.cidr').cidr });
|
|
142
|
-
});
|
|
209
|
+
policy.outbound = asArray(src.outbound, migrated ? LIMITS.v1Outbound : LIMITS.outbound, 'outbound').map((rule, i) => normalizeRule(rule, 'outbound', i));
|
|
210
|
+
policy.inbound = asArray(src.inbound, LIMITS.inbound, 'inbound').map((rule, i) => normalizeRule(rule, 'inbound', i));
|
|
143
211
|
const hostPorts = new Set();
|
|
144
212
|
asArray(src.expose, LIMITS.expose, 'expose').forEach(entry => {
|
|
145
213
|
const e = asObject(entry, 'expose[]');
|
|
@@ -157,6 +225,45 @@ function normalizePolicy(input) {
|
|
|
157
225
|
return policy;
|
|
158
226
|
}
|
|
159
227
|
|
|
228
|
+
const CONTAINER_REF = '@container:';
|
|
229
|
+
|
|
230
|
+
/** `@container:<名称>` → `@container:<id>`;lookup(名称) 返回 id 或空。已经是 id 的原样保留(容器删了规则也在)。 */
|
|
231
|
+
function resolveContainerRefs(policy, lookup) {
|
|
232
|
+
const fix = (rule, field) => {
|
|
233
|
+
const value = rule[field];
|
|
234
|
+
if (!value.startsWith(CONTAINER_REF)) return rule;
|
|
235
|
+
const ref = value.slice(CONTAINER_REF.length);
|
|
236
|
+
if (isValidId(ref)) return rule;
|
|
237
|
+
const id = lookup(ref);
|
|
238
|
+
if (!id) fail(`找不到容器: ${ref}`);
|
|
239
|
+
return { ...rule, [field]: `${CONTAINER_REF}${id}` };
|
|
240
|
+
};
|
|
241
|
+
return { ...policy, outbound: policy.outbound.map(r => fix(r, 'target')), inbound: policy.inbound.map(r => fix(r, 'source')) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 有没有按名称写的 @container:<名称>(需要查运行时换成 id)。 */
|
|
245
|
+
function hasNameRefs(policy) {
|
|
246
|
+
return [...policy.outbound.map(r => r.target), ...policy.inbound.map(r => r.source)].some(v => v.startsWith(CONTAINER_REF) && !isValidId(v.slice(CONTAINER_REF.length)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function containerRefOf(value) {
|
|
250
|
+
return String(value).startsWith(CONTAINER_REF) ? String(value).slice(CONTAINER_REF.length) : '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const targetOf = rule => (rule.target !== undefined ? rule.target : rule.source);
|
|
254
|
+
|
|
255
|
+
/** 只有“允许 @any”(或没有规则)的自定义策略 = 不限制:不建规则表。 */
|
|
256
|
+
function isUnrestricted(policy) {
|
|
257
|
+
if (policy.preset !== 'custom') return false;
|
|
258
|
+
const plain = rule => !rule.enabled || (rule.action === 'allow' && targetOf(rule) === '@any' && !rule.ports && rule.proto === 'all');
|
|
259
|
+
return policy.outbound.every(plain) && policy.inbound.every(plain);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 出站有启用的域名规则(tcp / all)→ 容器的 HTTP(S) 走过滤代理。 */
|
|
263
|
+
function usesProxy(policy) {
|
|
264
|
+
return policy.outbound.some(rule => rule.enabled && targetKind(rule.target) === 'domain' && rule.proto !== 'udp');
|
|
265
|
+
}
|
|
266
|
+
|
|
160
267
|
// ---- 域名匹配(过滤代理用) ----
|
|
161
268
|
|
|
162
269
|
/** pattern 为 `example.com`(只匹配自身)或 `*.example.com`(匹配任意子域,不含自身)。 */
|
|
@@ -170,6 +277,50 @@ function domainAllowed(domains, host) {
|
|
|
170
277
|
return (domains || []).some(pattern => domainMatches(pattern, host));
|
|
171
278
|
}
|
|
172
279
|
|
|
280
|
+
/** 出站域名规则从上往下第一条命中:返回规则,没有则 null(只看域名行,用于被拦截列表与一键放行)。 */
|
|
281
|
+
function firstDomainRule(policy, host) {
|
|
282
|
+
return policy.outbound.find(rule => rule.enabled && targetKind(rule.target) === 'domain' && domainMatches(rule.target, host)) || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function domainRuleAllows(policy, host) {
|
|
286
|
+
const rule = firstDomainRule(policy, host);
|
|
287
|
+
return Boolean(rule && rule.action === 'allow');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- 变量 → 地址集合(下发时现算;全部由宿主机侧数据得出) ----
|
|
291
|
+
// 地址集合 { any, inc, exc }:any 表示“所有地址”;inc 为包含的 CIDR;exc 为排除的 CIDR;空集合返回 null(该行不生成)
|
|
292
|
+
|
|
293
|
+
const withMask = ip => (ip.includes(':') ? `${ip}/128` : `${ip}/32`);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} target 规范化后的目标 / 来源
|
|
297
|
+
* @param {object} endpoints 见 buildNftRuleset
|
|
298
|
+
*/
|
|
299
|
+
function addressSetOf(target, endpoints = {}) {
|
|
300
|
+
const kind = targetKind(target);
|
|
301
|
+
if (kind === 'cidr') return { any: false, inc: [normalizeCidr(target).cidr], exc: [] };
|
|
302
|
+
if (kind === 'domain') return null;
|
|
303
|
+
const ips = list => list.map(ip => normalizeCidr(ip, 'ip').cidr);
|
|
304
|
+
switch (target) {
|
|
305
|
+
case '@any': return { any: true, inc: [], exc: [] };
|
|
306
|
+
case '@public': return { any: true, inc: [], exc: [...PRIVATE_V4, ...PRIVATE_V6, 'fe80::/10', '::1/128'] };
|
|
307
|
+
case '@private': return { any: false, inc: [...PRIVATE_V4, ...PRIVATE_V6, 'fe80::/10'], exc: [] };
|
|
308
|
+
case '@metadata': return { any: false, inc: [...METADATA_V4, ...METADATA_V6], exc: [] };
|
|
309
|
+
case '@host': return endpoints.hostIps && endpoints.hostIps.length ? { any: false, inc: ips(endpoints.hostIps), exc: [] } : null;
|
|
310
|
+
case '@containers': {
|
|
311
|
+
if (!endpoints.bridge) return null;
|
|
312
|
+
const subnet = normalizeCidr(endpoints.bridge.subnet, 'bridge.subnet');
|
|
313
|
+
const gateway = normalizeCidr(endpoints.bridge.gateway, 'bridge.gateway');
|
|
314
|
+
return subnet.family === 4 && gateway.family === 4 ? { any: false, inc: [subnet.cidr], exc: [withMask(gateway.cidr)] } : null;
|
|
315
|
+
}
|
|
316
|
+
default: {
|
|
317
|
+
const id = containerRefOf(target);
|
|
318
|
+
const ip = id && endpoints.containers ? endpoints.containers[id] : '';
|
|
319
|
+
return ip ? { any: false, inc: ips([ip]), exc: [] } : null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
173
324
|
// ---- nft 生成 ----
|
|
174
325
|
|
|
175
326
|
function portSet(ports) {
|
|
@@ -177,39 +328,76 @@ function portSet(ports) {
|
|
|
177
328
|
return normalized.includes(',') ? `{ ${normalized.split(',').join(', ')} }` : normalized;
|
|
178
329
|
}
|
|
179
330
|
|
|
180
|
-
|
|
181
|
-
const { cidr: c, family } = normalizeCidr(cidr, label);
|
|
182
|
-
return `${family === 4 ? 'ip' : 'ip6'} ${direction} ${c}`;
|
|
183
|
-
}
|
|
184
|
-
|
|
331
|
+
// proto=all 且无端口 → 不限协议;有端口 → 只能 tcp / udp
|
|
185
332
|
function portClause(proto, ports) {
|
|
186
|
-
return ports ? `
|
|
333
|
+
if (proto === 'all') return ports ? ` meta l4proto { tcp, udp } th dport ${portSet(ports)}` : '';
|
|
334
|
+
const p = normalizeProto(proto);
|
|
335
|
+
return ports ? ` ${p} dport ${portSet(ports)}` : ` meta l4proto ${p}`;
|
|
187
336
|
}
|
|
188
337
|
|
|
189
|
-
function
|
|
338
|
+
function cidrsOfFamily(cidrs, family) {
|
|
190
339
|
return cidrs.map(c => normalizeCidr(c)).filter(c => c.family === family).map(c => c.cidr);
|
|
191
340
|
}
|
|
192
341
|
|
|
342
|
+
/** 地址集合 → nft 地址条件列表(每个元素是一行规则里的地址部分;[''] 表示不限地址)。 */
|
|
343
|
+
function addrClauses(direction, set) {
|
|
344
|
+
if (!set) return [];
|
|
345
|
+
if (set.any && !set.exc.length) return [''];
|
|
346
|
+
const clauses = [];
|
|
347
|
+
for (const family of [4, 6]) {
|
|
348
|
+
const prefix = family === 4 ? 'ip' : 'ip6';
|
|
349
|
+
const inc = cidrsOfFamily(set.inc, family);
|
|
350
|
+
const exc = cidrsOfFamily(set.exc, family);
|
|
351
|
+
if (!set.any && !inc.length) continue;
|
|
352
|
+
const parts = [];
|
|
353
|
+
if (inc.length) parts.push(`${prefix} ${direction} { ${inc.join(', ')} }`);
|
|
354
|
+
if (exc.length) parts.push(`${prefix} ${direction} != { ${exc.join(', ')} }`);
|
|
355
|
+
clauses.push(parts.join(' '));
|
|
356
|
+
}
|
|
357
|
+
return clauses;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const LINK_LOCAL = 'fe80::/10';
|
|
361
|
+
|
|
362
|
+
// plainReject:不限协议、不限端口的拒绝行。发往 fe80::/10 的 ICMPv6(邻居发现)不能拒绝,否则 IPv6 邻居解析会坏,
|
|
363
|
+
// 所以这部分只拒绝 tcp / udp
|
|
364
|
+
function pushRule(lines, direction, set, tail, plainReject = false) {
|
|
365
|
+
if (plainReject && direction === 'daddr' && set && set.inc.includes(LINK_LOCAL)) {
|
|
366
|
+
pushRule(lines, direction, { ...set, inc: set.inc.filter(c => c !== LINK_LOCAL) }, tail);
|
|
367
|
+
lines.push(` meta l4proto { tcp, udp } ip6 daddr ${LINK_LOCAL}${tail}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
addrClauses(direction, set).forEach(addr => lines.push(` ${addr}${tail}`.replace(/^ {4} /, ' ')));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 模式默认行:restricted 拒绝其他容器 / 宿主机 / 内网 / 元数据后放行公网;allowlist 拒绝一切;custom 没有
|
|
374
|
+
const DEFAULT_ROWS = {
|
|
375
|
+
restricted: { outbound: ['@containers', '@host', '@private', '@metadata'], inbound: ['@containers'] },
|
|
376
|
+
allowlist: { outbound: ['@any'], inbound: ['@containers'] },
|
|
377
|
+
custom: { outbound: [], inbound: [] }
|
|
378
|
+
};
|
|
379
|
+
|
|
193
380
|
/**
|
|
194
381
|
* @param {object} policy normalizePolicy 的结果
|
|
195
382
|
* @param {object} endpoints 运行时解析出的地址(下发时现算,全部重新校验)
|
|
196
383
|
* @param {string[]} [endpoints.dns]
|
|
197
|
-
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo
|
|
198
|
-
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.peerIn] 允许进来的 peer
|
|
384
|
+
* @param {{ip:string, ports:string, proto?:string}[]} [endpoints.allow] manyoyo 必需端点(上游代理、过滤代理、Playwright、镜像)与派生的容器间放行
|
|
199
385
|
* @param {{daddr:string, saddr:string}[]} [endpoints.antiSpoof] 发往 daddr 的包来源必须是 saddr(过滤代理按来源 IP 认容器)
|
|
200
386
|
* @param {{subnet:string, gateway:string}|null} [endpoints.bridge]
|
|
201
|
-
* @
|
|
387
|
+
* @param {string[]} [endpoints.hostIps] 容器看到的宿主机 IP(@host)
|
|
388
|
+
* @param {Object<string,string>} [endpoints.containers] 运行中容器 id → IP(@container:<id>)
|
|
389
|
+
* @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);不限制的策略只删表
|
|
202
390
|
*/
|
|
203
391
|
function buildNftRuleset(policy, endpoints = {}) {
|
|
204
392
|
const lines = ['add table inet manyoyo', 'delete table inet manyoyo'];
|
|
205
|
-
if (policy
|
|
393
|
+
if (isUnrestricted(policy)) return `${lines.join('\n')}\n`;
|
|
206
394
|
|
|
207
395
|
const out = [' oif lo accept', ' ct state established,related accept'];
|
|
208
396
|
(endpoints.antiSpoof || []).forEach(item => {
|
|
209
|
-
|
|
397
|
+
const daddr = normalizeCidr(item.daddr, 'antiSpoof');
|
|
398
|
+
const saddr = normalizeCidr(item.saddr, 'antiSpoof');
|
|
399
|
+
out.push(` ${daddr.family === 4 ? 'ip' : 'ip6'} daddr ${daddr.cidr} ${saddr.family === 4 ? 'ip' : 'ip6'} saddr != ${saddr.cidr} drop`);
|
|
210
400
|
});
|
|
211
|
-
// 用户 deny 排在所有放行规则之前,才能真正“禁止”
|
|
212
|
-
if (policy.preset !== 'open') policy.deny.forEach(rule => out.push(` ${addrExpr('daddr', rule.cidr, 'deny')} reject`));
|
|
213
401
|
(endpoints.dns || []).forEach(ip => {
|
|
214
402
|
const { cidr, family } = normalizeCidr(ip, 'dns');
|
|
215
403
|
const prefix = family === 4 ? 'ip' : 'ip6';
|
|
@@ -217,34 +405,24 @@ function buildNftRuleset(policy, endpoints = {}) {
|
|
|
217
405
|
out.push(` ${prefix} daddr ${cidr} tcp dport 53 accept`);
|
|
218
406
|
});
|
|
219
407
|
(endpoints.allow || []).forEach(item => {
|
|
220
|
-
|
|
408
|
+
const { cidr, family } = normalizeCidr(item.ip, 'allow');
|
|
409
|
+
out.push(` ${family === 4 ? 'ip' : 'ip6'} daddr ${cidr}${portClause(item.proto || 'tcp', item.ports)} accept`);
|
|
221
410
|
});
|
|
222
|
-
//
|
|
223
|
-
policy.
|
|
224
|
-
out
|
|
411
|
+
// 用户规则:从上往下,第一条命中的生效(域名规则由过滤代理执行,不进 nft)
|
|
412
|
+
policy.outbound.filter(rule => rule.enabled && targetKind(rule.target) !== 'domain').forEach(rule => {
|
|
413
|
+
pushRule(out, 'daddr', addressSetOf(rule.target, endpoints), `${portClause(rule.proto, rule.ports)} ${rule.action === 'allow' ? 'accept' : 'reject'}`, rule.action === 'deny' && rule.proto === 'all' && !rule.ports);
|
|
414
|
+
});
|
|
415
|
+
DEFAULT_ROWS[policy.preset].outbound.forEach(target => {
|
|
416
|
+
pushRule(out, 'daddr', addressSetOf(target, endpoints), ' reject', true);
|
|
225
417
|
});
|
|
226
|
-
if (policy.preset === 'allowlist') {
|
|
227
|
-
out.push(' reject');
|
|
228
|
-
} else {
|
|
229
|
-
// restricted:先放行用户 deny 之外的公网,再封私有网段
|
|
230
|
-
const v4 = cidrList(PRIVATE_V4, 4);
|
|
231
|
-
const v6 = cidrList(PRIVATE_V6, 6);
|
|
232
|
-
out.push(` ip daddr { ${v4.join(', ')} } reject`);
|
|
233
|
-
out.push(` ip6 daddr { ${v6.join(', ')} } reject`);
|
|
234
|
-
out.push(' meta l4proto { tcp, udp } ip6 daddr fe80::/10 reject');
|
|
235
|
-
}
|
|
236
418
|
|
|
237
419
|
const inn = [' iif lo accept', ' ct state established,related accept'];
|
|
238
|
-
(
|
|
239
|
-
inn
|
|
420
|
+
policy.inbound.filter(rule => rule.enabled && targetKind(rule.source) !== 'domain').forEach(rule => {
|
|
421
|
+
pushRule(inn, 'saddr', addressSetOf(rule.source, endpoints), `${portClause(rule.proto, rule.ports)}${rule.action === 'allow' ? ' accept' : ' ct state new reject'}`);
|
|
422
|
+
});
|
|
423
|
+
DEFAULT_ROWS[policy.preset].inbound.forEach(target => {
|
|
424
|
+
pushRule(inn, 'saddr', addressSetOf(target, endpoints), ' ct state new reject');
|
|
240
425
|
});
|
|
241
|
-
if (endpoints.bridge) {
|
|
242
|
-
const subnet = normalizeCidr(endpoints.bridge.subnet, 'bridge.subnet');
|
|
243
|
-
const gateway = normalizeCidr(endpoints.bridge.gateway, 'bridge.gateway');
|
|
244
|
-
if (subnet.family === 4 && gateway.family === 4) {
|
|
245
|
-
inn.push(` ip saddr ${subnet.cidr} ip saddr != ${gateway.cidr} ct state new reject`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
426
|
|
|
249
427
|
lines.push('table inet manyoyo {');
|
|
250
428
|
lines.push(' chain output {', ' type filter hook output priority 0; policy accept;', ...out, ' }');
|
|
@@ -253,17 +431,58 @@ function buildNftRuleset(policy, endpoints = {}) {
|
|
|
253
431
|
return `${lines.join('\n')}\n`;
|
|
254
432
|
}
|
|
255
433
|
|
|
434
|
+
/**
|
|
435
|
+
* 过滤代理按容器编译好的有序规则(clients.json 里的一项):域名行原样,其余展开成 CIDR 集合,然后是模式默认行。
|
|
436
|
+
* 过滤代理只做 tcp;udp 行不进来。
|
|
437
|
+
* @param {object} endpoints 同 buildNftRuleset,另有 derived: {ip, ports, proto}[](其他容器入站放行本容器的派生行)
|
|
438
|
+
*/
|
|
439
|
+
function compileProxyPolicy(policy, endpoints = {}) {
|
|
440
|
+
const rules = [];
|
|
441
|
+
const addAddr = (action, target, ports, proto) => {
|
|
442
|
+
if (proto === 'udp') return;
|
|
443
|
+
const set = addressSetOf(target, endpoints);
|
|
444
|
+
if (set) rules.push({ action, addr: set, ports: ports || '', proto });
|
|
445
|
+
};
|
|
446
|
+
(endpoints.derived || []).forEach(item => addAddr('allow', item.ip, item.ports, item.proto || 'tcp'));
|
|
447
|
+
policy.outbound.filter(rule => rule.enabled).forEach(rule => {
|
|
448
|
+
if (targetKind(rule.target) === 'domain') {
|
|
449
|
+
if (rule.proto !== 'udp') rules.push({ action: rule.action, domain: rule.target, ports: rule.ports, proto: rule.proto });
|
|
450
|
+
} else {
|
|
451
|
+
addAddr(rule.action, rule.target, rule.ports, rule.proto);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
DEFAULT_ROWS[policy.preset].outbound.filter(target => target !== '@any').forEach(target => addAddr('deny', target, '', 'all'));
|
|
455
|
+
const sensitive = ['@host', '@private', '@metadata'].flatMap(target => (addressSetOf(target, endpoints) || { inc: [] }).inc);
|
|
456
|
+
return { preset: policy.preset, default: policy.preset === 'allowlist' ? 'deny' : 'allow', sensitive, rules };
|
|
457
|
+
}
|
|
458
|
+
|
|
256
459
|
module.exports = {
|
|
257
460
|
PRESETS,
|
|
461
|
+
PROTOS,
|
|
462
|
+
LIMITS,
|
|
258
463
|
PRIVATE_V4,
|
|
259
464
|
PRIVATE_V6,
|
|
465
|
+
METADATA_V4,
|
|
466
|
+
METADATA_V6,
|
|
467
|
+
DEFAULT_ROWS,
|
|
260
468
|
normalizePorts,
|
|
261
469
|
normalizeProto,
|
|
262
470
|
normalizeCidr,
|
|
263
471
|
normalizeDomain,
|
|
472
|
+
normalizeTarget,
|
|
264
473
|
normalizePolicy,
|
|
265
474
|
defaultPolicy,
|
|
475
|
+
targetKind,
|
|
476
|
+
resolveContainerRefs,
|
|
477
|
+
hasNameRefs,
|
|
478
|
+
containerRefOf,
|
|
479
|
+
isUnrestricted,
|
|
480
|
+
usesProxy,
|
|
266
481
|
domainMatches,
|
|
267
482
|
domainAllowed,
|
|
268
|
-
|
|
483
|
+
firstDomainRule,
|
|
484
|
+
domainRuleAllows,
|
|
485
|
+
addressSetOf,
|
|
486
|
+
buildNftRuleset,
|
|
487
|
+
compileProxyPolicy
|
|
269
488
|
};
|
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,network/allow,network/denied,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag
|
|
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)防止覆盖容器内的修改;放开类改动(切到 `custom`、新增很宽的允许规则、非 loopback 暴露)服务端也要求 `confirmRisk:true`;`network` 是 v2 规则表(`outbound` / `inbound`,旧 v1 读取时自动转换,`@container:<名称>` 保存时换成 id)。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start / die)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;事件流断开重连(运行时重启)后 `reconcileContainerServices` 再对账一次;过滤代理是 sidecar 容器(`lib/egress-sidecar.js`),由网络管理器 `ensure()` 保证在运行,不在 serve 进程里。`GET network` 带 `denied`(最近被拦截,已放行的不列)、`derived`(别的容器入站放行本容器产生的出站派生行)与 `peers[].ip`;`network/allow` 在出站用户规则最前插入允许域名行;`/agent/stream` 进行中 `createWebDeniedNotifier` 每 2 秒读一次拦截记录,有新域名就推一条 `trace`(只有 `text`,同一域名同一轮一次)。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
|
|
27
27
|
|
|
28
28
|
## lib/web/index.html
|
|
29
29
|
|