@xcanwin/manyoyo 8.4.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- // 域名白名单的强制执行点:serve 进程内的 HTTP(S) 过滤代理。
11
- // 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按域名放行 CONNECT / 普通 HTTP,
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:端口。监听 0.0.0.0(容器只能经宿主机 LAN IP 访问宿主机),靠凭据挡局域网。
14
- const REALM = 'manyoyo';
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 {(id: string) => string|null} options.getToken 容器的代理 token(没有返回 null)
94
- * @param {(id: string) => {preset: string, domains: string[], rules: object[]}|null} options.getPolicy
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 getToken = options.getToken;
100
- const getPolicy = options.getPolicy;
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
- let upstream = null;
103
- if (options.upstream) {
104
- try {
105
- const url = new URL(options.upstream);
106
- if (url.protocol === 'http:') {
107
- upstream = { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) || 80, auth: url.username ? `Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64')}` : '' };
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
- } catch (e) { /* 无效上游当作没有 */ }
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 authenticate(req) {
116
- const cred = parseBasicAuth(req.headers['proxy-authorization']);
117
- if (!cred || !isValidId(cred.id)) return null;
118
- const token = getToken(cred.id);
119
- if (!token || !tokenEquals(token, cred.token)) return null;
120
- return cred.id;
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(id, host, port) {
125
- const policy = getPolicy(id);
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 headers = { 'Content-Type': 'text/plain; charset=utf-8', Connection: 'close' };
158
- if (status === 407) headers['Proxy-Authenticate'] = `Basic realm="${REALM}"`;
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(`manyoyo egress proxy: ${message}\n`);
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 === 407 ? `Proxy-Authenticate: Basic realm="${REALM}"\r\n` : '';
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 id = authenticate(req);
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 verdict = await authorize(id, target.host, target.port);
194
- if (!verdict.ok) return rawReject(clientSocket, verdict.status, verdict.status === 403 ? 'Forbidden' : 'Bad Gateway');
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 id = authenticate(req);
243
- if (!id) return reject(res, 407, 'proxy authentication required');
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, 'absolute URI required'); }
246
- if (url.protocol !== 'http:') return reject(res, 400, 'only http: URIs');
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 verdict = await authorize(id, host, port);
250
- if (!verdict.ok) return reject(res, verdict.status, verdict.status === 403 ? 'forbidden by policy' : 'bad gateway');
251
- if (!acquire(id)) return reject(res, 429, 'too many connections');
252
- const headers = { ...req.headers };
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, 'bad gateway'); else res.destroy(); });
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, 'error'); } catch (e) { /* 连接已断 */ } }); });
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, parseBasicAuth, parseTarget, REALM };
315
+ module.exports = { createEgressProxy, isRestrictedAddress, ruleCovers, parseTarget };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { createEgressProxy } = require('./egress-proxy');
6
+ const { createRecorder } = require('./egress-denied');
7
+
8
+ // 过滤代理 sidecar 容器里的入口(宿主机把这几个文件只读挂进来,见 egress-sidecar.js)。
9
+ // 数据目录(只读):clients.json(来源 IP → 容器 id + 策略,宿主机在每次下发规则时原子替换)、upstream.txt(可选,上游代理);
10
+ // 拒绝记录目录(读写):<容器 id>.jsonl。
11
+ const CLIENTS_FILE = 'clients.json';
12
+ const UPSTREAM_FILE = 'upstream.txt';
13
+ const RELOAD_CHECK_MS = 500;
14
+
15
+ function loadClients(file) {
16
+ try {
17
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
18
+ const clients = parsed && typeof parsed.clients === 'object' && parsed.clients ? parsed.clients : {};
19
+ return new Map(Object.entries(clients));
20
+ } catch (e) {
21
+ return new Map(); // 读不到 / 损坏:谁都不认识,全部 403(失败即关闭)
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {object} options
27
+ * @param {string} options.dataDir
28
+ * @param {string} options.deniedDir
29
+ * @param {number} options.port
30
+ * @param {string} [options.host]
31
+ * @param {(host: string) => Promise<string[]>} [options.lookup]
32
+ */
33
+ async function start(options) {
34
+ const clientsFile = path.join(options.dataDir, CLIENTS_FILE);
35
+ // 上游代理:upstream.txt 变了就热更新(CLI 与 serve 的环境可能不同,不能为此重建容器)
36
+ const upstreamFile = path.join(options.dataDir, UPSTREAM_FILE);
37
+ let upstream = '';
38
+ let upstreamSig = '';
39
+ let upstreamCheckedAt = 0;
40
+ const getUpstream = () => {
41
+ const nowMs = Date.now();
42
+ if (nowMs - upstreamCheckedAt < RELOAD_CHECK_MS) return upstream;
43
+ upstreamCheckedAt = nowMs;
44
+ try {
45
+ const stat = fs.statSync(upstreamFile);
46
+ const sig = `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
47
+ if (sig !== upstreamSig) {
48
+ upstreamSig = sig;
49
+ upstream = fs.readFileSync(upstreamFile, 'utf-8').trim();
50
+ }
51
+ } catch (e) {
52
+ upstreamSig = '';
53
+ upstream = '';
54
+ }
55
+ return upstream;
56
+ };
57
+
58
+ let clients = loadClients(clientsFile);
59
+ let signature = '';
60
+ let checkedAt = 0;
61
+ const refresh = () => {
62
+ const nowMs = Date.now();
63
+ if (nowMs - checkedAt < RELOAD_CHECK_MS) return;
64
+ checkedAt = nowMs;
65
+ try {
66
+ const stat = fs.statSync(clientsFile);
67
+ const next = `${stat.mtimeMs}:${stat.size}:${stat.ino}`;
68
+ if (next !== signature) {
69
+ signature = next;
70
+ clients = loadClients(clientsFile);
71
+ }
72
+ } catch (e) {
73
+ signature = '';
74
+ clients = new Map();
75
+ }
76
+ };
77
+
78
+ const recorder = createRecorder({ dir: options.deniedDir });
79
+ const proxy = createEgressProxy({
80
+ upstream: getUpstream,
81
+ lookup: options.lookup,
82
+ getClient: ip => {
83
+ refresh();
84
+ const entry = clients.get(ip);
85
+ return entry ? { ...entry } : null;
86
+ },
87
+ onDenied: event => recorder.record(event)
88
+ });
89
+ const port = await proxy.start({ host: options.host || '0.0.0.0', port: options.port });
90
+ return {
91
+ port,
92
+ stop: async () => { recorder.stop(); await proxy.stop(); }
93
+ };
94
+ }
95
+
96
+ if (require.main === module) {
97
+ start({
98
+ dataDir: process.env.EGRESS_DATA_DIR || '/data',
99
+ deniedDir: process.env.EGRESS_DENIED_DIR || '/denied',
100
+ port: Number(process.env.EGRESS_PORT) || 3128
101
+ }).then(server => {
102
+ process.stdout.write(`egress-proxy ready ${server.port}\n`);
103
+ const shutdown = () => server.stop().finally(() => process.exit(0));
104
+ process.on('SIGTERM', shutdown);
105
+ process.on('SIGINT', shutdown);
106
+ }).catch(error => {
107
+ process.stderr.write(`egress-proxy failed: ${error.message}\n`);
108
+ process.exit(1);
109
+ });
110
+ }
111
+
112
+ module.exports = { start, CLIENTS_FILE, UPSTREAM_FILE };
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const net = require('net');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { writeConfigFileSecure } = require('./secure-file');
9
+
10
+ // 域名白名单的强制执行点:manyoyo 网络里的一个专用小容器(sidecar),里面用镜像自带的 node 跑 egress-proxy。
11
+ // - 容器按“来源 IP”识别:宿主机在每次下发规则时把「容器 IP → 容器 id + 策略」写进 clients.json(只读挂入),
12
+ // 认不出的来源一律 403;宿主机上不监听任何端口,同机其他用户无法占用或冒充。
13
+ // - 固定 IP(--ip):重启后不变,容器的代理地址与 nft 规则才能稳定。
14
+ // - sidecar 只读挂入程序文件和数据目录,只有拒绝记录目录可写;丢掉全部能力。
15
+ const SIDECAR_PORT = 3128;
16
+ const APP_FILES = ['egress-sidecar-main.js', 'egress-proxy.js', 'egress-denied.js', 'network-policy.js', 'container-id.js'];
17
+ const READY_TIMEOUT_MS = 15000;
18
+
19
+ function egressDir(homeDir) {
20
+ return path.join(homeDir, '.manyoyo', 'egress');
21
+ }
22
+
23
+ function egressPaths(homeDir) {
24
+ const dir = egressDir(homeDir);
25
+ return { dir, app: path.join(dir, 'app'), data: path.join(dir, 'data'), denied: path.join(dir, 'denied') };
26
+ }
27
+
28
+ function homeHash(homeDir) {
29
+ return crypto.createHash('sha256').update(path.resolve(homeDir)).digest('hex').slice(0, 8);
30
+ }
31
+
32
+ function sidecarName(homeDir) {
33
+ return `manyoyo-egress-${homeHash(homeDir)}`;
34
+ }
35
+
36
+ // 任何 HOME 的过滤代理 sidecar 名字:容器列表(网页 / ls)里要排除,它不是用户的会话容器
37
+ function isSidecarName(name) {
38
+ return /^manyoyo-egress-[0-9a-f]{8}$/.test(String(name || ''));
39
+ }
40
+
41
+ function ensureDir(dir) {
42
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ fs.chmodSync(dir, 0o700); // 只限制宿主机其他用户;容器里的 root(rootless 下就是宿主机用户,rootful 下是真 root)不受影响
44
+ }
45
+
46
+ function writeAtomic(file, text, mode) {
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, text, { mode });
49
+ fs.chmodSync(tmp, mode);
50
+ fs.renameSync(tmp, file);
51
+ }
52
+
53
+ function ipv4ToInt(ip) {
54
+ return ip.split('.').reduce((acc, part) => ((acc << 8) | Number(part)) >>> 0, 0);
55
+ }
56
+
57
+ function intToIpv4(value) {
58
+ return [24, 16, 8, 0].map(shift => (value >>> shift) & 255).join('.');
59
+ }
60
+
61
+ /**
62
+ * 在 manyoyo 网络网段里选 sidecar 的固定地址:网段内偏移 250(/24 即 .250),不同 HOME 的实例在
63
+ * 250 往下的几个地址里按 HOME 哈希错开;真的撞上了由 `run --ip` 报“地址已占用”,不会随机换。
64
+ */
65
+ function chooseSidecarIp(subnet, homeDir) {
66
+ const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(String(subnet || ''));
67
+ if (!m || !net.isIPv4(m[1])) throw new Error(`无法确定 manyoyo 网络的网段: ${subnet}`);
68
+ const prefix = Number(m[2]);
69
+ if (prefix < 8 || prefix > 29) throw new Error(`manyoyo 网络网段不适合放过滤代理: ${subnet}`);
70
+ const size = 2 ** (32 - prefix);
71
+ const base = (ipv4ToInt(m[1]) & ((0xffffffff << (32 - prefix)) >>> 0)) >>> 0;
72
+ const slot = parseInt(homeHash(homeDir), 16) % 5;
73
+ const offset = size > 256 ? 250 - slot : Math.max(2, size - 6 - slot);
74
+ return intToIpv4((base + offset) >>> 0);
75
+ }
76
+
77
+ // docker:IPAM.Config[].Subnet;podman 4/5:subnets[].subnet;返回第一个 IPv4 网段
78
+ function subnetOfNetwork(inspectJson) {
79
+ const parsed = Array.isArray(inspectJson) ? inspectJson[0] : inspectJson;
80
+ const candidates = [];
81
+ ((parsed && parsed.IPAM && parsed.IPAM.Config) || []).forEach(c => candidates.push(c.Subnet));
82
+ ((parsed && (parsed.subnets || parsed.Subnets)) || []).forEach(c => candidates.push(c.subnet || c.Subnet));
83
+ return candidates.find(s => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(String(s || ''))) || '';
84
+ }
85
+
86
+ // 上游代理在宿主机上的地址对 sidecar 容器可能不通:环回地址改写成容器看到的宿主机别名
87
+ function containerSideUpstream(upstream, hostAlias) {
88
+ if (!upstream) return '';
89
+ try {
90
+ const url = new URL(upstream);
91
+ if (url.protocol !== 'http:') return '';
92
+ if (/^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?)$/i.test(url.hostname)) url.hostname = hostAlias;
93
+ return url.toString().replace(/\/$/, '');
94
+ } catch (e) {
95
+ return '';
96
+ }
97
+ }
98
+
99
+ // 固定 MAC(同 docker 的 02:42:<IP 四字节> 规则):重启后 MAC 变了,别的容器里缓存的 ARP 要等一分钟才失效,期间出网不通
100
+ function macForIp(ip) {
101
+ return `02:42:${ip.split('.').map(part => Number(part).toString(16).padStart(2, '0')).join(':')}`;
102
+ }
103
+
104
+ function appFileContents() {
105
+ return APP_FILES.map(name => [name, fs.readFileSync(path.join(__dirname, name), 'utf-8')]);
106
+ }
107
+
108
+ /**
109
+ * @param {object} options
110
+ * @param {(args: string[], opts?: object) => Promise<string>} options.run 运行时命令(容器运行时 CLI)
111
+ * @param {string} options.command 运行时命令名(docker / podman,用来判断宿主机别名)
112
+ * @param {string} options.homeDir
113
+ * @param {() => string} options.imageRef 当前镜像
114
+ * @param {string} options.networkName manyoyo 网络名
115
+ * @param {() => Promise<void>} options.ensureNetwork
116
+ * @param {() => string} [options.getUpstream] 上游代理(宿主机视角)
117
+ */
118
+ function createSidecarManager(options) {
119
+ const { run, homeDir, networkName } = options;
120
+ const getUpstream = options.getUpstream || (() => '');
121
+ const isDocker = /docker/i.test(path.basename(String(options.command || '')));
122
+ const hostAlias = isDocker ? 'host.docker.internal' : 'host.containers.internal';
123
+ const name = sidecarName(homeDir);
124
+ const hash = homeHash(homeDir);
125
+ let inflight = null;
126
+ let lastClients = '';
127
+
128
+ async function inspectSidecar() {
129
+ try {
130
+ const out = await run(['inspect', '--format', '{{json .}}', name]);
131
+ const line = out.split('\n').map(l => l.trim()).filter(Boolean)[0];
132
+ return line ? JSON.parse(line) : null;
133
+ } catch (e) {
134
+ if (/no such|not found|no container|does not exist/i.test(String(e.message))) return null;
135
+ throw e;
136
+ }
137
+ }
138
+
139
+ function ipOf(info) {
140
+ const networks = (info.NetworkSettings && info.NetworkSettings.Networks) || {};
141
+ return (networks[networkName] && networks[networkName].IPAddress) || '';
142
+ }
143
+
144
+ function runningOf(info) {
145
+ return Boolean(info.State && (info.State.Running === true || info.State.Status === 'running'));
146
+ }
147
+
148
+ function prepareFiles(upstream) {
149
+ const p = egressPaths(homeDir);
150
+ ensureDir(path.join(homeDir, '.manyoyo'));
151
+ ensureDir(p.dir);
152
+ [p.app, p.data, p.denied].forEach(ensureDir);
153
+ const contents = appFileContents();
154
+ contents.forEach(([file, text]) => {
155
+ const target = path.join(p.app, file);
156
+ if (!fs.existsSync(target) || fs.readFileSync(target, 'utf-8') !== text) writeAtomic(target, text, 0o644);
157
+ });
158
+ const upstreamFile = path.join(p.data, 'upstream.txt');
159
+ if (upstream) writeConfigFileSecure(upstreamFile, `${upstream}\n`);
160
+ else fs.rmSync(upstreamFile, { force: true });
161
+ const rev = crypto.createHash('sha256');
162
+ contents.forEach(([file, text]) => rev.update(`${file}\0${text}\0`));
163
+ return { p, rev: rev.digest('hex').slice(0, 16) };
164
+ }
165
+
166
+ // 就绪 = 在 sidecar 里真的连得上代理端口(不能看日志:重启后日志里还留着上一次的“ready”)
167
+ async function waitReady() {
168
+ const deadline = Date.now() + READY_TIMEOUT_MS;
169
+ const probe = `require('net').connect(${SIDECAR_PORT},'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))`;
170
+ while (Date.now() < deadline) {
171
+ const info = await inspectSidecar();
172
+ if (!info || !runningOf(info)) break;
173
+ try {
174
+ await run(['exec', name, 'node', '-e', probe], { timeout: 5000 });
175
+ return;
176
+ } catch (e) { /* 还没起来 */ }
177
+ await new Promise(resolve => setTimeout(resolve, 150));
178
+ }
179
+ let tail = '';
180
+ try { tail = await run(['logs', '--tail', '5', name]); } catch (e) { /* 取不到就算了 */ }
181
+ throw new Error(`域名白名单代理没有起来${tail ? `: ${String(tail).trim().split('\n').slice(-2).join(' ')}` : ''}`);
182
+ }
183
+
184
+ async function doEnsure() {
185
+ await options.ensureNetwork();
186
+ const subnet = subnetOfNetwork(JSON.parse((await run(['network', 'inspect', networkName, '--format', '{{json .}}'])).trim().split('\n')[0] || 'null'));
187
+ const ip = chooseSidecarIp(subnet, homeDir);
188
+ const upstream = containerSideUpstream(getUpstream(), hostAlias);
189
+ const { p, rev } = prepareFiles(upstream);
190
+ let info = await inspectSidecar();
191
+ const labels = (info && info.Config && info.Config.Labels) || {};
192
+ const matches = info && labels['manyoyo.egress.rev'] === rev && labels['manyoyo.home'] === hash;
193
+ if (matches && runningOf(info) && ipOf(info) === ip) return { name, ip, port: SIDECAR_PORT };
194
+ if (matches && !runningOf(info)) {
195
+ try {
196
+ await run(['start', name]);
197
+ info = await inspectSidecar();
198
+ if (info && runningOf(info) && ipOf(info) === ip) {
199
+ await waitReady();
200
+ return { name, ip, port: SIDECAR_PORT };
201
+ }
202
+ } catch (e) { /* 起不来就重建 */ }
203
+ }
204
+ if (info) await run(['rm', '-f', name]);
205
+ const args = [
206
+ 'run', '-d', '--name', name,
207
+ '--network', networkName, '--ip', ip, '--mac-address', macForIp(ip),
208
+ // 宿主机上这几个目录属于普通用户且是 0700:rootful 运行时里的 root 不带 DAC_OVERRIDE 就进不去(rootless 下 root 就是该用户,不受影响)
209
+ '--user', 'root', '--cap-drop', 'ALL', '--cap-add', 'DAC_OVERRIDE', '--security-opt', 'no-new-privileges',
210
+ '--read-only', '--tmpfs', '/tmp', '--pids-limit', '256', '--memory', '256m',
211
+ '--pull=never',
212
+ '--label', 'manyoyo.role=egress', '--label', `manyoyo.home=${hash}`, '--label', `manyoyo.egress.rev=${rev}`,
213
+ '--volume', `${p.app}:/app:ro`, '--volume', `${p.data}:/data:ro`, '--volume', `${p.denied}:/denied`,
214
+ '--env', `EGRESS_PORT=${SIDECAR_PORT}`
215
+ ];
216
+ if (isDocker) args.push('--add-host', 'host.docker.internal:host-gateway');
217
+ args.push('--entrypoint', 'node', options.imageRef(), '/app/egress-sidecar-main.js');
218
+ try {
219
+ await run(args, { timeout: 60000 });
220
+ } catch (e) {
221
+ const message = String(e.message);
222
+ // 另一个进程(CLI / serve)刚好同时建好了它:直接用现成的
223
+ if (/name .*(already in use|is already used)|already in use by container|name is already/i.test(message)) {
224
+ const other = await inspectSidecar();
225
+ if (other && runningOf(other) && ipOf(other) === ip) {
226
+ await waitReady();
227
+ return { name, ip, port: SIDECAR_PORT };
228
+ }
229
+ }
230
+ throw new Error(/address already in use|ip.*(used|allocated)|in use/i.test(message) && !/name/i.test(message)
231
+ ? `域名白名单代理需要的地址 ${ip} 已被占用(${networkName} 网络里有别的容器用了它)`
232
+ : `域名白名单代理启动失败: ${message}`);
233
+ }
234
+ await waitReady();
235
+ return { name, ip, port: SIDECAR_PORT };
236
+ }
237
+
238
+ /** 保证 sidecar 在运行,返回 {name, ip, port};多处同时调用合并成一次。 */
239
+ function ensure() {
240
+ if (!inflight) inflight = doEnsure().finally(() => { inflight = null; });
241
+ return inflight;
242
+ }
243
+
244
+ /** 只读检查:sidecar 现在是否在运行且地址正确(ensureReady 的快速路径用)。 */
245
+ async function isHealthy() {
246
+ const info = await inspectSidecar();
247
+ return Boolean(info && runningOf(info));
248
+ }
249
+
250
+ /** 写入「来源 IP → 容器 id + 策略」;内容没变就不写(避免无谓刷新)。 */
251
+ function writeClients(clients) {
252
+ const p = egressPaths(homeDir);
253
+ ensureDir(p.dir);
254
+ ensureDir(p.data);
255
+ const text = `${JSON.stringify({ clients }, null, 2)}\n`;
256
+ if (text === lastClients && fs.existsSync(path.join(p.data, 'clients.json'))) return;
257
+ writeAtomic(path.join(p.data, 'clients.json'), text, 0o644);
258
+ lastClients = text;
259
+ }
260
+
261
+ return { name, ensure, isHealthy, writeClients, paths: () => egressPaths(homeDir), isSidecarName: n => n === name };
262
+ }
263
+
264
+ module.exports = {
265
+ SIDECAR_PORT,
266
+ APP_FILES,
267
+ egressDir,
268
+ egressPaths,
269
+ sidecarName,
270
+ isSidecarName,
271
+ chooseSidecarIp,
272
+ macForIp,
273
+ subnetOfNetwork,
274
+ containerSideUpstream,
275
+ createSidecarManager
276
+ };
@@ -232,7 +232,7 @@ function applyTextReplacements(text, replacements) {
232
232
  }
233
233
 
234
234
  function formatPropertyKey(key) {
235
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
235
+ return JSON.stringify(key);
236
236
  }
237
237
 
238
238
  // 某个位置所在行的缩进
@@ -246,8 +246,7 @@ function reindent(valueText, indent) {
246
246
  return String(valueText).replace(/\n/g, `\n${indent}`);
247
247
  }
248
248
 
249
- // 永远只在对象的 '{' 后面插入,不管这个对象原来是空的还是已经有属性——
250
- // 不用去判断"是不是最后一个属性、要不要补逗号",语法上总是安全的
249
+ // 永远只在对象的 '{' 后面插入;对象原来已有属性才补逗号,空对象里插入第一个属性不留尾逗号
251
250
  function insertObjectProperty(text, objectStartIndex, key, valueText) {
252
251
  const objectIndent = lineIndentAt(text, objectStartIndex);
253
252
  const childIndent = `${objectIndent} `;
@@ -256,8 +255,9 @@ function insertObjectProperty(text, objectStartIndex, key, valueText) {
256
255
  const lineEnd = text.indexOf('\n', objectStartIndex + 1);
257
256
  const tailIsTrivia = lineEnd !== -1 && /^\s*(\/\/.*)?$/.test(text.slice(objectStartIndex + 1, lineEnd));
258
257
  // 空对象且 } 紧跟在同一行:闭合的 } 另起一行;} 已在后面的行上就不再补,避免多出空行
259
- const closesRightAfter = !tailIsTrivia && text[skipTrivia(text, objectStartIndex + 1)] === '}';
260
- const insertion = `${prefix}${reindent(valueText, childIndent)},${closesRightAfter ? `\n${objectIndent}` : ''}`;
258
+ const nextChar = text[skipTrivia(text, objectStartIndex + 1)];
259
+ const closesRightAfter = !tailIsTrivia && nextChar === '}';
260
+ const insertion = `${prefix}${reindent(valueText, childIndent)}${nextChar === '}' ? '' : ','}${closesRightAfter ? `\n${objectIndent}` : ''}`;
261
261
  const insertPos = tailIsTrivia ? lineEnd : objectStartIndex + 1;
262
262
  const nextText = `${text.slice(0, insertPos)}${insertion}${text.slice(insertPos)}`;
263
263
  return { text: nextText, valueStart: insertPos + prefix.length };