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