@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.
@@ -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', 'open', 'allowlist'];
10
- const PROTOS = ['tcp', 'udp'];
11
- const LIMITS = { domains: 200, rules: 100, host: 50, inbound: 50, deny: 100, expose: 20, portItems: 32 };
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 === '' ? 'tcp' : input).toLowerCase();
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: 1,
103
+ version: 2,
96
104
  preset: 'restricted',
97
- egress: { domains: [], rules: [] },
98
- host: [],
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
- const src = asObject(input, '网络策略');
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
- const egress = asObject(src.egress, 'egress');
117
- const seenDomains = new Set();
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
- function addrExpr(direction, cidr, label) {
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 ? ` ${normalizeProto(proto)} dport ${portSet(ports)}` : ` meta l4proto ${normalizeProto(proto)}`;
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 cidrList(cidrs, family) {
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 必需端点、上游代理、过滤代理、Playwright、镜像、host 端口、出站 peer
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
- * @returns {string} 整份 nft 脚本(先删后建,一次 nft -f 原子替换);open 预设只删表
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.preset === 'open') return `${lines.join('\n')}\n`;
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
- out.push(` ${addrExpr('daddr', item.daddr, 'antiSpoof')} ${addrExpr('saddr', item.saddr, 'antiSpoof').replace(' saddr ', ' saddr != ')} drop`);
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
- out.push(` ${addrExpr('daddr', item.ip, 'allow')}${portClause(item.proto, item.ports)} accept`);
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
- // egress.rules:直连放行的 IP 规则(restricted 下用来放行私有网段里的指定端点,allowlist 下是唯一的直连出口)
223
- policy.egress.rules.forEach(rule => {
224
- out.push(` ${addrExpr('daddr', rule.cidr, 'egress')}${portClause(rule.proto, rule.ports)} accept`);
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
- (endpoints.peerIn || []).forEach(item => {
239
- inn.push(` ${addrExpr('saddr', item.ip, 'peer')}${portClause(item.proto, item.ports)} accept`);
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
- buildNftRuleset
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)防止覆盖容器内的修改;放开类改动(`open`、非 loopback 暴露)服务端也要求 `confirmRisk:true`。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start / die)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;事件流断开重连(运行时重启)后 `reconcileContainerServices` 再对账一次;过滤代理是 sidecar 容器(`lib/egress-sidecar.js`),由网络管理器 `ensure()` 保证在运行,不在 serve 进程里。`GET network` 带 `denied`(最近被拦截,已放行的不列);`/agent/stream` 进行中 `createWebDeniedNotifier` 每 2 秒读一次拦截记录,有新域名就推一条 `trace`(只有 `text`,同一域名同一轮一次)。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
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