@xcanwin/manyoyo 8.3.3 → 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/README.md +1 -1
- package/bin/manyoyo.js +111 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/browser-background.js +23 -0
- package/lib/container-env.sh +34 -0
- package/lib/container-exec.js +132 -0
- package/lib/container-id.js +11 -0
- package/lib/container-init.sh +40 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +615 -0
- package/lib/container-run.js +27 -3
- package/lib/container-state.js +467 -0
- package/lib/egress-denied.js +128 -0
- package/lib/egress-proxy.js +315 -0
- package/lib/egress-sidecar-main.js +112 -0
- package/lib/egress-sidecar.js +276 -0
- package/lib/env-text.js +62 -0
- package/lib/json5-text-edit.js +5 -5
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +269 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +137 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +506 -0
- package/lib/web/denied-notify.js +52 -0
- package/lib/web/index.html +81 -77
- package/lib/web/server.js +352 -135
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const dns = require('dns').promises;
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const net = require('net');
|
|
6
|
+
const { domainAllowed, PRIVATE_V4, PRIVATE_V6 } = require('./network-policy');
|
|
7
|
+
|
|
8
|
+
// 域名白名单的强制执行点:运行在 manyoyo 网络里专用 sidecar 容器中的 HTTP(S) 过滤代理(见 egress-sidecar.js)。
|
|
9
|
+
// 开启 allowlist 的容器防火墙只放行到这个代理,直连被挡;这里按连接的来源 IP 认出是哪个容器(getClient),
|
|
10
|
+
// 认不出的来源一律 403;按域名放行 CONNECT / 普通 HTTP,
|
|
11
|
+
// 并且解析出的 IP 属于私有 / 环回 / 链路本地段一律拒绝(防借代理打宿主机 loopback 与 DNS 重绑定),
|
|
12
|
+
// 除非该容器的 egress.rules 显式放行该 IP:端口。
|
|
13
|
+
// 被拒绝时统一回 403(带 Proxy-Status,不带任何能暴露代理身份的内容),并通过 onDenied 记录。
|
|
14
|
+
const CONNECT_TIMEOUT_MS = 15000;
|
|
15
|
+
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
const MAX_TUNNELS_PER_CONTAINER = 256;
|
|
17
|
+
|
|
18
|
+
const blocked = new net.BlockList();
|
|
19
|
+
[...PRIVATE_V4, '127.0.0.0/8', '224.0.0.0/4', '255.255.255.255/32'].forEach(cidr => {
|
|
20
|
+
const [addr, prefix] = cidr.split('/');
|
|
21
|
+
blocked.addSubnet(addr, Number(prefix), 'ipv4');
|
|
22
|
+
});
|
|
23
|
+
[...PRIVATE_V6, 'fe80::/10', 'ff00::/8', '::1/128', '::/128'].forEach(cidr => {
|
|
24
|
+
const [addr, prefix] = cidr.split('/');
|
|
25
|
+
blocked.addSubnet(addr, Number(prefix), 'ipv6');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ::ffff:a.b.c.d 与 NAT64(64:ff9b::/96)里嵌的 IPv4 要按 IPv4 判断
|
|
29
|
+
function embeddedV4(ip) {
|
|
30
|
+
const lower = ip.toLowerCase();
|
|
31
|
+
const mapped = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
32
|
+
if (mapped) return mapped[1];
|
|
33
|
+
const hex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
|
|
34
|
+
if (hex) {
|
|
35
|
+
const a = parseInt(hex[1], 16);
|
|
36
|
+
const b = parseInt(hex[2], 16);
|
|
37
|
+
return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRestrictedAddress(ip) {
|
|
43
|
+
const v4 = embeddedV4(ip);
|
|
44
|
+
if (v4) return blocked.check(v4, 'ipv4');
|
|
45
|
+
const family = net.isIP(ip);
|
|
46
|
+
if (!family) return true;
|
|
47
|
+
return blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ruleCovers(rule, ip, port) {
|
|
51
|
+
if (rule.proto && rule.proto !== 'tcp') return false;
|
|
52
|
+
if (rule.ports) {
|
|
53
|
+
const ok = rule.ports.split(',').some(item => {
|
|
54
|
+
const [a, b] = item.split('-').map(Number);
|
|
55
|
+
return port >= a && port <= (b === undefined ? a : b);
|
|
56
|
+
});
|
|
57
|
+
if (!ok) return false;
|
|
58
|
+
}
|
|
59
|
+
const [addr, prefixText] = rule.cidr.split('/');
|
|
60
|
+
const family = net.isIP(addr);
|
|
61
|
+
if (!family || net.isIP(ip) !== family) return false;
|
|
62
|
+
const list = new net.BlockList();
|
|
63
|
+
list.addSubnet(addr, prefixText === undefined ? (family === 4 ? 32 : 128) : Number(prefixText), family === 4 ? 'ipv4' : 'ipv6');
|
|
64
|
+
return list.check(ip, family === 4 ? 'ipv4' : 'ipv6');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseTarget(text) {
|
|
68
|
+
const m = /^(?:\[([0-9a-fA-F:.]+)\]|([^:/\s]+)):(\d{1,5})$/.exec(text);
|
|
69
|
+
if (!m) return null;
|
|
70
|
+
const port = Number(m[3]);
|
|
71
|
+
if (port < 1 || port > 65535) return null;
|
|
72
|
+
return { host: (m[1] || m[2]).toLowerCase().replace(/\.$/, ''), port };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} options
|
|
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] 拒绝记录
|
|
79
|
+
* @param {string} [options.upstream] 上游 HTTP 代理 URL(串联)
|
|
80
|
+
* @param {(host: string) => Promise<string[]>} [options.lookup] 测试替身
|
|
81
|
+
*/
|
|
82
|
+
function createEgressProxy(options) {
|
|
83
|
+
const getClient = options.getClient;
|
|
84
|
+
const onDenied = options.onDenied || (() => {});
|
|
85
|
+
const lookup = options.lookup || (async host => (await dns.lookup(host, { all: true })).map(r => r.address));
|
|
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) { /* 无效上游当作没有 */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return parsedUpstream;
|
|
104
|
+
}
|
|
105
|
+
const tunnels = new Map();
|
|
106
|
+
const sockets = new Set(); // CONNECT 劫持出来的 socket 不在 http.Server 的跟踪里,stop 时要自己关
|
|
107
|
+
let server = null;
|
|
108
|
+
|
|
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;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 返回 {ok:true, ip?} 或 {ok:false, status, message}
|
|
122
|
+
async function authorize(policy, host, port, upstream) {
|
|
123
|
+
if (!policy || policy.preset !== 'allowlist') return { ok: false, status: 403, message: 'domain' };
|
|
124
|
+
const isIp = net.isIP(host) !== 0;
|
|
125
|
+
const explicit = (ip, p) => (policy.rules || []).some(rule => ruleCovers(rule, ip, p));
|
|
126
|
+
if (isIp) {
|
|
127
|
+
if (isRestrictedAddress(host) && !explicit(host, port)) return { ok: false, status: 403, message: 'address' };
|
|
128
|
+
if (!explicit(host, port)) return { ok: false, status: 403, message: 'domain' };
|
|
129
|
+
return { ok: true, ip: host };
|
|
130
|
+
}
|
|
131
|
+
if (!domainAllowed(policy.domains, host)) return { ok: false, status: 403, message: 'domain' };
|
|
132
|
+
if (upstream) {
|
|
133
|
+
// 经上游代理时真正的连接由上游解析;这里仍做一次本地解析,解析得到私有 / 环回地址(且没有被显式放行)就拒绝。
|
|
134
|
+
// 本地解析不出来(宿主机不一定能解析)则放行给上游。残余风险:白名单里的通配域名若被控制了 DNS,
|
|
135
|
+
// 本地与上游可能得到不同答案——不要在白名单里放你不信任的通配域名。
|
|
136
|
+
try {
|
|
137
|
+
const local = await lookup(host);
|
|
138
|
+
if (local.length && local.every(ip => isRestrictedAddress(ip) && !explicit(ip, port))) return { ok: false, status: 403, message: 'address' };
|
|
139
|
+
} catch (e) { /* 本地解析失败:交给上游 */ }
|
|
140
|
+
return { ok: true, ip: '' };
|
|
141
|
+
}
|
|
142
|
+
let addresses;
|
|
143
|
+
try {
|
|
144
|
+
addresses = await lookup(host);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { ok: false, status: 502, message: 'dns' };
|
|
147
|
+
}
|
|
148
|
+
const usable = addresses.filter(ip => !isRestrictedAddress(ip) || explicit(ip, port));
|
|
149
|
+
if (!usable.length) return { ok: false, status: 403, message: 'address' };
|
|
150
|
+
return { ok: true, ip: usable[0] };
|
|
151
|
+
}
|
|
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
|
+
|
|
158
|
+
function reject(res, status, message) {
|
|
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);
|
|
162
|
+
res.writeHead(status, headers);
|
|
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) { /* 记录失败不影响拒绝 */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function acquire(id) {
|
|
171
|
+
const n = tunnels.get(id) || 0;
|
|
172
|
+
if (n >= MAX_TUNNELS_PER_CONTAINER) return false;
|
|
173
|
+
tunnels.set(id, n + 1);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function release(id) {
|
|
178
|
+
const n = (tunnels.get(id) || 1) - 1;
|
|
179
|
+
if (n <= 0) tunnels.delete(id); else tunnels.set(id, n);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rawReject(socket, status, text, message) {
|
|
183
|
+
const extra = status === 403 ? `Proxy-Status: ${statusHeader(message)}\r\n` : '';
|
|
184
|
+
socket.end(`HTTP/1.1 ${status} ${text}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function track(socket) {
|
|
188
|
+
sockets.add(socket);
|
|
189
|
+
socket.once('close', () => sockets.delete(socket));
|
|
190
|
+
return socket;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function onConnect(req, clientSocket, head) {
|
|
194
|
+
track(clientSocket);
|
|
195
|
+
clientSocket.on('error', () => {});
|
|
196
|
+
const client = identify(clientSocket);
|
|
197
|
+
const target = parseTarget(String(req.url || ''));
|
|
198
|
+
if (!client) return rawReject(clientSocket, 403, 'Forbidden', 'domain');
|
|
199
|
+
if (!target) return rawReject(clientSocket, 400, 'Bad Request');
|
|
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
|
+
}
|
|
207
|
+
if (!acquire(id)) return rawReject(clientSocket, 429, 'Too Many Requests');
|
|
208
|
+
let released = false;
|
|
209
|
+
const done = () => { if (!released) { released = true; release(id); } };
|
|
210
|
+
let upstreamSocket;
|
|
211
|
+
if (upstream) {
|
|
212
|
+
upstreamSocket = track(net.connect({ host: upstream.host, port: upstream.port }));
|
|
213
|
+
upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
214
|
+
upstreamSocket.once('connect', () => {
|
|
215
|
+
upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
216
|
+
const authLine = upstream.auth ? `Proxy-Authorization: ${upstream.auth}\r\n` : '';
|
|
217
|
+
const hostPort = net.isIPv6(target.host) ? `[${target.host}]:${target.port}` : `${target.host}:${target.port}`;
|
|
218
|
+
upstreamSocket.write(`CONNECT ${hostPort} HTTP/1.1\r\nHost: ${hostPort}\r\n${authLine}\r\n`);
|
|
219
|
+
let buf = Buffer.alloc(0);
|
|
220
|
+
const onData = chunk => {
|
|
221
|
+
buf = Buffer.concat([buf, chunk]);
|
|
222
|
+
const end = buf.indexOf('\r\n\r\n');
|
|
223
|
+
if (end < 0) { if (buf.length > 16384) upstreamSocket.destroy(); return; }
|
|
224
|
+
upstreamSocket.removeListener('data', onData);
|
|
225
|
+
const status = Number((/^HTTP\/1\.[01] (\d{3})/.exec(buf.toString('latin1', 0, end)) || [])[1]);
|
|
226
|
+
if (status !== 200) { rawReject(clientSocket, 502, 'Bad Gateway'); upstreamSocket.destroy(); return; }
|
|
227
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
228
|
+
const rest = buf.subarray(end + 4);
|
|
229
|
+
if (rest.length) clientSocket.write(rest);
|
|
230
|
+
if (head && head.length) upstreamSocket.write(head);
|
|
231
|
+
upstreamSocket.pipe(clientSocket);
|
|
232
|
+
clientSocket.pipe(upstreamSocket);
|
|
233
|
+
};
|
|
234
|
+
upstreamSocket.on('data', onData);
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
upstreamSocket = track(net.connect({ host: verdict.ip, port: target.port }));
|
|
238
|
+
upstreamSocket.setTimeout(CONNECT_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
239
|
+
upstreamSocket.once('connect', () => {
|
|
240
|
+
upstreamSocket.setTimeout(IDLE_TIMEOUT_MS, () => upstreamSocket.destroy());
|
|
241
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
242
|
+
if (head && head.length) upstreamSocket.write(head);
|
|
243
|
+
upstreamSocket.pipe(clientSocket);
|
|
244
|
+
clientSocket.pipe(upstreamSocket);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
upstreamSocket.on('error', () => { if (!clientSocket.destroyed) rawReject(clientSocket, 502, 'Bad Gateway'); });
|
|
248
|
+
upstreamSocket.on('close', () => { done(); clientSocket.destroy(); });
|
|
249
|
+
clientSocket.on('close', () => { done(); upstreamSocket.destroy(); });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 普通 HTTP 代理(绝对 URI 请求)
|
|
253
|
+
async function onRequest(req, res) {
|
|
254
|
+
const client = identify(req.socket);
|
|
255
|
+
if (!client) return reject(res, 403, 'domain');
|
|
256
|
+
const id = client.id;
|
|
257
|
+
let url;
|
|
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');
|
|
260
|
+
const port = Number(url.port) || 80;
|
|
261
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
|
|
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)
|
|
270
|
+
delete headers['proxy-authorization'];
|
|
271
|
+
delete headers['proxy-connection'];
|
|
272
|
+
const outbound = upstream
|
|
273
|
+
? { host: upstream.host, port: upstream.port, path: req.url, headers: { ...headers, ...(upstream.auth ? { 'proxy-authorization': upstream.auth } : {}) } }
|
|
274
|
+
: { host: verdict.ip, port, path: `${url.pathname}${url.search}`, headers };
|
|
275
|
+
const proxyReq = http.request({ ...outbound, method: req.method, timeout: CONNECT_TIMEOUT_MS });
|
|
276
|
+
let released = false;
|
|
277
|
+
const done = () => { if (!released) { released = true; release(id); } };
|
|
278
|
+
proxyReq.on('response', proxyRes => {
|
|
279
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
280
|
+
proxyRes.pipe(res);
|
|
281
|
+
});
|
|
282
|
+
proxyReq.on('timeout', () => proxyReq.destroy());
|
|
283
|
+
proxyReq.on('error', () => { if (!res.headersSent) reject(res, 502, 'upstream'); else res.destroy(); });
|
|
284
|
+
res.on('close', () => { done(); proxyReq.destroy(); });
|
|
285
|
+
req.pipe(proxyReq);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function start(listen) {
|
|
289
|
+
return new Promise((resolve, fail) => {
|
|
290
|
+
server = http.createServer((req, res) => { onRequest(req, res).catch(() => { try { reject(res, 500, 'upstream'); } catch (e) { /* 连接已断 */ } }); });
|
|
291
|
+
server.on('connect', (req, socket, head) => { onConnect(req, socket, head).catch(() => socket.destroy()); });
|
|
292
|
+
server.headersTimeout = 20000;
|
|
293
|
+
server.on('clientError', (err, socket) => socket.destroy());
|
|
294
|
+
server.once('error', fail);
|
|
295
|
+
server.listen(listen.port, listen.host, () => {
|
|
296
|
+
server.removeListener('error', fail);
|
|
297
|
+
resolve(server.address().port);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function stop() {
|
|
303
|
+
return new Promise(resolve => {
|
|
304
|
+
if (!server) return resolve();
|
|
305
|
+
server.close(() => resolve());
|
|
306
|
+
server.closeAllConnections && server.closeAllConnections();
|
|
307
|
+
sockets.forEach(socket => socket.destroy());
|
|
308
|
+
server = null;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { start, stop, authorize, isListening: () => Boolean(server && server.listening) };
|
|
313
|
+
}
|
|
314
|
+
|
|
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 };
|