ipv6-bridge 1.0.0 → 2.1.1
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 +206 -31
- package/docs/API.md +365 -0
- package/docs/ARCHITECTURE.md +218 -0
- package/docs/CHANGELOG.md +159 -0
- package/docs/CONTRIBUTING.md +88 -0
- package/docs/GUIDE.md +675 -0
- package/docs/ROADMAP.md +69 -0
- package/examples/basic-usage.js +31 -23
- package/examples/embedded-usage.js +100 -0
- package/examples/production-usage.js +106 -0
- package/package.json +6 -6
- package/src/agent.js +271 -0
- package/src/cache.js +108 -0
- package/src/cli.js +265 -48
- package/src/config.js +159 -24
- package/src/connect.js +142 -0
- package/src/detect.js +82 -43
- package/src/discovery.js +122 -0
- package/src/dns64.js +226 -44
- package/src/doctor.js +222 -0
- package/src/index.js +157 -62
- package/src/ipv6.js +232 -0
- package/src/logger.js +47 -0
- package/src/netmatch.js +122 -0
- package/src/proxy.js +419 -101
- package/src/socks5.js +283 -0
- package/src/stats.js +140 -0
package/src/proxy.js
CHANGED
|
@@ -1,141 +1,459 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IPv6 Bridge - HTTP/HTTPS Proxy
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* DNS64-aware forward proxy. It accepts HTTP requests and CONNECT tunnels,
|
|
5
|
+
* resolves the target through DNS64, and routes traffic over IPv6 so an
|
|
6
|
+
* upstream NAT64 gateway can reach IPv4-only servers.
|
|
6
7
|
*
|
|
7
8
|
* @module proxy
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const http = require('http');
|
|
11
|
-
const
|
|
12
|
-
const {
|
|
13
|
-
const
|
|
12
|
+
const { detectIPVersion, dnsCache } = require('./dns64');
|
|
13
|
+
const { connectWithFallback, BridgeAgent } = require('./connect');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
const stats = require('./stats');
|
|
16
|
+
const log = require('./logger');
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
19
|
+
* Headers that apply to a single transport hop and must not be forwarded
|
|
20
|
+
* (RFC 7230 section 6.1). Proxy-Authorization is credentials for this proxy;
|
|
21
|
+
* forwarding it leaks them to every origin server.
|
|
22
|
+
*/
|
|
23
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
24
|
+
'connection',
|
|
25
|
+
'proxy-connection',
|
|
26
|
+
'keep-alive',
|
|
27
|
+
'proxy-authenticate',
|
|
28
|
+
'proxy-authorization',
|
|
29
|
+
'te',
|
|
30
|
+
'trailer',
|
|
31
|
+
'transfer-encoding',
|
|
32
|
+
'upgrade',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const CONTROL_PATHS = new Set(['/healthz', '/status', '/metrics', '/proxy.pac']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove hop-by-hop headers, including any listed in the Connection header.
|
|
17
39
|
*
|
|
18
|
-
* @param {
|
|
19
|
-
* @returns {
|
|
40
|
+
* @param {object} headers - Incoming headers
|
|
41
|
+
* @returns {object} Headers safe to forward
|
|
20
42
|
*/
|
|
21
|
-
|
|
22
|
-
const
|
|
43
|
+
function sanitizeHeaders(headers) {
|
|
44
|
+
const connectionTokens = new Set();
|
|
45
|
+
const connection = headers.connection || headers.Connection;
|
|
46
|
+
if (connection) {
|
|
47
|
+
for (const token of String(connection).split(',')) {
|
|
48
|
+
connectionTokens.add(token.trim().toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
23
51
|
|
|
24
|
-
|
|
25
|
-
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
54
|
+
const lower = name.toLowerCase();
|
|
55
|
+
if (HOP_BY_HOP_HEADERS.has(lower) || connectionTokens.has(lower)) continue;
|
|
56
|
+
result[name] = value;
|
|
26
57
|
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Strip brackets from an IPv6 literal host ("[::1]" -> "::1").
|
|
63
|
+
*
|
|
64
|
+
* @param {string} host - Host which may be a bracketed IPv6 literal
|
|
65
|
+
* @returns {string} Bare host
|
|
66
|
+
*/
|
|
67
|
+
function stripBrackets(host) {
|
|
68
|
+
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
69
|
+
}
|
|
27
70
|
|
|
28
|
-
|
|
29
|
-
|
|
71
|
+
function isAbsoluteForm(target) {
|
|
72
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(target);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse the request target of a proxied HTTP request.
|
|
77
|
+
*
|
|
78
|
+
* Clients configured to use a forward proxy send absolute-form targets
|
|
79
|
+
* ("GET http://example.com/path HTTP/1.1", RFC 7230 section 5.3.2).
|
|
80
|
+
* Origin-form is accepted as a fallback for direct/gateway-style use.
|
|
81
|
+
*
|
|
82
|
+
* @param {http.IncomingMessage} req - Incoming request
|
|
83
|
+
* @returns {URL|null} Parsed target, or null if it cannot be determined
|
|
84
|
+
*/
|
|
85
|
+
function parseRequestTarget(req) {
|
|
30
86
|
try {
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
}
|
|
87
|
+
if (isAbsoluteForm(req.url)) return new URL(req.url);
|
|
88
|
+
if (!req.headers.host) return null;
|
|
89
|
+
return new URL(`http://${req.headers.host}${req.url}`);
|
|
35
90
|
} catch {
|
|
36
|
-
|
|
91
|
+
return null;
|
|
37
92
|
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a CONNECT authority ("example.com:443", "[::1]:443", "10.0.0.1:8443").
|
|
97
|
+
*
|
|
98
|
+
* @param {string} authority - The CONNECT request target
|
|
99
|
+
* @returns {{hostname: string, port: number}|null} Parsed target, or null if malformed
|
|
100
|
+
*/
|
|
101
|
+
function parseAuthority(authority) {
|
|
102
|
+
if (!authority) return null;
|
|
38
103
|
|
|
39
|
-
return {
|
|
104
|
+
if (detectIPVersion(authority) === 'ipv6') return { hostname: authority, port: 443 };
|
|
105
|
+
|
|
106
|
+
const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(authority);
|
|
107
|
+
if (bracketed) {
|
|
108
|
+
return { hostname: bracketed[1], port: bracketed[2] ? Number(bracketed[2]) : 443 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const separator = authority.lastIndexOf(':');
|
|
112
|
+
if (separator === -1) return { hostname: authority, port: 443 };
|
|
113
|
+
|
|
114
|
+
const port = authority.slice(separator + 1);
|
|
115
|
+
if (!/^\d+$/.test(port)) return null;
|
|
116
|
+
|
|
117
|
+
return { hostname: authority.slice(0, separator), port: Number(port) };
|
|
40
118
|
}
|
|
41
119
|
|
|
42
120
|
/**
|
|
43
|
-
*
|
|
121
|
+
* Constant-time-ish comparison for credentials.
|
|
44
122
|
*
|
|
45
|
-
* @param {
|
|
46
|
-
* @
|
|
123
|
+
* @param {string} a - First value
|
|
124
|
+
* @param {string} b - Second value
|
|
125
|
+
* @returns {boolean} true if equal
|
|
47
126
|
*/
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
function safeEqual(a, b) {
|
|
128
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
|
|
129
|
+
let diff = 0;
|
|
130
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
131
|
+
return diff === 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decide whether a client may use the proxy.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} remoteAddress - Client address
|
|
138
|
+
* @param {object} headers - Request headers
|
|
139
|
+
* @returns {{allowed: boolean, status?: number, reason?: string}} Access decision
|
|
140
|
+
*/
|
|
141
|
+
function checkAccess(remoteAddress, headers) {
|
|
142
|
+
if (!config.ALLOW_FROM.isEmpty && !config.ALLOW_FROM.matches(remoteAddress)) {
|
|
143
|
+
return { allowed: false, status: 403, reason: `client ${remoteAddress} is not in the allowlist` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (config.AUTH) {
|
|
147
|
+
const provided = headers['proxy-authorization'];
|
|
148
|
+
if (!provided || !safeEqual(provided.trim(), config.AUTH.header)) {
|
|
149
|
+
return { allowed: false, status: 407, reason: 'missing or invalid proxy credentials' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { allowed: true };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function shouldBypass(hostname) {
|
|
157
|
+
return !config.BYPASS.isEmpty && config.BYPASS.matches(hostname);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the PAC file describing how clients should route through the bridge.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} host - Proxy host as clients should reach it
|
|
164
|
+
* @param {number} port - Proxy port
|
|
165
|
+
* @returns {string} PAC script
|
|
166
|
+
*/
|
|
167
|
+
function buildPacFile(host, port) {
|
|
168
|
+
const proxyHost = host === '::' || host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
169
|
+
const bypassRules = config.BYPASS.rules;
|
|
170
|
+
|
|
171
|
+
const bypassChecks = bypassRules.map((rule) => {
|
|
172
|
+
if (rule.startsWith('*.')) {
|
|
173
|
+
return ` if (dnsDomainIs(host, ${JSON.stringify(rule.slice(1))})) return "DIRECT";`;
|
|
174
|
+
}
|
|
175
|
+
if (rule.includes('/')) {
|
|
176
|
+
const [network, bits] = rule.split('/');
|
|
177
|
+
return ` if (isInNet(host, ${JSON.stringify(network)}, ${JSON.stringify(cidrToMask(Number(bits)))})) return "DIRECT";`;
|
|
178
|
+
}
|
|
179
|
+
return ` if (host === ${JSON.stringify(rule)}) return "DIRECT";`;
|
|
180
|
+
}).join('\n');
|
|
181
|
+
|
|
182
|
+
return `function FindProxyForURL(url, host) {
|
|
183
|
+
// Loopback and local names never need the bridge.
|
|
184
|
+
if (isPlainHostName(host) ||
|
|
185
|
+
shExpMatch(host, "localhost") ||
|
|
186
|
+
isInNet(host, "127.0.0.0", "255.0.0.0")) {
|
|
187
|
+
return "DIRECT";
|
|
188
|
+
}
|
|
189
|
+
${bypassChecks ? bypassChecks + '\n' : ''}
|
|
190
|
+
return "PROXY ${proxyHost}:${port}";
|
|
191
|
+
}
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cidrToMask(bits) {
|
|
196
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > 32) return '255.255.255.255';
|
|
197
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
198
|
+
return [24, 16, 8, 0].map((shift) => (mask >>> shift) & 0xff).join('.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Serve an operational endpoint (health, status, metrics, PAC).
|
|
203
|
+
*
|
|
204
|
+
* @param {string} path - Request path
|
|
205
|
+
* @param {http.ServerResponse} res - Response to write to
|
|
206
|
+
* @param {{host: string, port: number}} address - Proxy listen address
|
|
207
|
+
*/
|
|
208
|
+
function serveControl(path, res, address) {
|
|
209
|
+
const extra = () => ({
|
|
210
|
+
dnsCache: dnsCache.stats(),
|
|
211
|
+
nat64Prefix: `${config.getPrefix().prefix}/${config.getPrefix().length}`,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (path === '/healthz') {
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
216
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (path === '/status') {
|
|
221
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
222
|
+
res.end(JSON.stringify(stats.snapshot(extra()), null, 2));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (path === '/metrics') {
|
|
227
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4' });
|
|
228
|
+
res.end(stats.toPrometheus(extra()));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (path === '/proxy.pac') {
|
|
233
|
+
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' });
|
|
234
|
+
res.end(buildPacFile(address.host, address.port));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not Found');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function createRequestHandler(agent, address) {
|
|
242
|
+
return function handleRequest(req, res) {
|
|
243
|
+
const path = isAbsoluteForm(req.url) ? null : req.url.split('?')[0];
|
|
244
|
+
const isControlRequest = config.CONTROL_ENDPOINTS && path && CONTROL_PATHS.has(path);
|
|
245
|
+
|
|
246
|
+
// Health checks must work without credentials so load balancers can use them.
|
|
247
|
+
if (isControlRequest && path === '/healthz') {
|
|
248
|
+
serveControl(path, res, address);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const access = checkAccess(req.socket.remoteAddress, req.headers);
|
|
253
|
+
if (!access.allowed) {
|
|
254
|
+
stats.counters.authFailures += 1;
|
|
255
|
+
log.warn(`Rejected request from ${req.socket.remoteAddress}: ${access.reason}`);
|
|
256
|
+
const headers = { 'Content-Type': 'text/plain' };
|
|
257
|
+
if (access.status === 407) {
|
|
258
|
+
headers['Proxy-Authenticate'] = 'Basic realm="ipv6-bridge"';
|
|
95
259
|
}
|
|
260
|
+
res.writeHead(access.status, headers).end(
|
|
261
|
+
access.status === 407 ? 'Proxy Authentication Required' : 'Forbidden'
|
|
262
|
+
);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (isControlRequest) {
|
|
267
|
+
serveControl(path, res, address);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
stats.counters.httpRequests += 1;
|
|
272
|
+
|
|
273
|
+
const url = parseRequestTarget(req);
|
|
274
|
+
if (!url) {
|
|
275
|
+
log.warn(`Rejecting request with unparseable target: ${req.method} ${req.url}`);
|
|
276
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const hostname = stripBrackets(url.hostname);
|
|
281
|
+
const headers = sanitizeHeaders(req.headers);
|
|
282
|
+
headers.via = `1.1 ipv6-bridge${req.headers.via ? ', ' + req.headers.via : ''}`;
|
|
283
|
+
|
|
284
|
+
if (shouldBypass(hostname)) stats.recordRoute('bypassed');
|
|
285
|
+
|
|
286
|
+
const proxyReq = http.request({
|
|
287
|
+
host: hostname,
|
|
288
|
+
port: Number(url.port) || 80,
|
|
289
|
+
path: url.pathname + url.search,
|
|
290
|
+
method: req.method,
|
|
291
|
+
headers,
|
|
292
|
+
agent,
|
|
293
|
+
timeout: config.CONNECTION_TIMEOUT,
|
|
294
|
+
}, (proxyRes) => {
|
|
295
|
+
stats.recordStatus(proxyRes.statusCode);
|
|
296
|
+
|
|
297
|
+
const responseHeaders = sanitizeHeaders(proxyRes.headers);
|
|
298
|
+
responseHeaders.via = `1.1 ipv6-bridge${proxyRes.headers.via ? ', ' + proxyRes.headers.via : ''}`;
|
|
299
|
+
|
|
300
|
+
res.writeHead(proxyRes.statusCode, responseHeaders);
|
|
301
|
+
proxyRes.on('data', (chunk) => { stats.counters.bytesToClient += chunk.length; });
|
|
302
|
+
proxyRes.pipe(res);
|
|
303
|
+
proxyRes.on('error', () => res.destroy());
|
|
96
304
|
});
|
|
97
305
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
306
|
+
proxyReq.on('error', (err) => {
|
|
307
|
+
stats.counters.proxyErrors += 1;
|
|
308
|
+
log.warn(`Upstream error for ${hostname}: ${err.message}`);
|
|
309
|
+
if (!res.headersSent) {
|
|
310
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' }).end('Bad Gateway');
|
|
311
|
+
} else {
|
|
312
|
+
res.destroy();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
103
315
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
316
|
+
proxyReq.on('timeout', () => {
|
|
317
|
+
stats.counters.timeouts += 1;
|
|
318
|
+
log.warn(`Upstream timeout for ${hostname} after ${config.CONNECTION_TIMEOUT}ms`);
|
|
319
|
+
proxyReq.destroy();
|
|
320
|
+
if (!res.headersSent) {
|
|
321
|
+
res.writeHead(504, { 'Content-Type': 'text/plain' }).end('Gateway Timeout');
|
|
322
|
+
} else {
|
|
323
|
+
res.destroy();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
108
326
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
327
|
+
req.on('data', (chunk) => { stats.counters.bytesToUpstream += chunk.length; });
|
|
328
|
+
req.pipe(proxyReq);
|
|
329
|
+
req.on('error', () => proxyReq.destroy());
|
|
330
|
+
res.on('close', () => proxyReq.destroy());
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function handleConnect(req, clientSocket, head) {
|
|
335
|
+
const access = checkAccess(clientSocket.remoteAddress, req.headers);
|
|
336
|
+
if (!access.allowed) {
|
|
337
|
+
stats.counters.authFailures += 1;
|
|
338
|
+
log.warn(`Rejected CONNECT from ${clientSocket.remoteAddress}: ${access.reason}`);
|
|
339
|
+
clientSocket.end(access.status === 407
|
|
340
|
+
? 'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="ipv6-bridge"\r\n\r\n'
|
|
341
|
+
: 'HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const target = parseAuthority(req.url);
|
|
346
|
+
if (!target || target.port < 1 || target.port > 65535) {
|
|
347
|
+
log.warn(`Rejecting malformed CONNECT target: ${req.url}`);
|
|
348
|
+
clientSocket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
stats.counters.connectRequests += 1;
|
|
353
|
+
|
|
354
|
+
connectWithFallback(target.hostname, target.port, { bypass: shouldBypass(target.hostname) })
|
|
355
|
+
.then(({ socket: upstream, candidate }) => {
|
|
356
|
+
if (clientSocket.destroyed) {
|
|
357
|
+
upstream.destroy();
|
|
358
|
+
return;
|
|
128
359
|
}
|
|
360
|
+
|
|
361
|
+
log.debug(`CONNECT ${req.url} -> ${candidate.host}:${target.port} via ${candidate.mode}`);
|
|
362
|
+
|
|
363
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
364
|
+
if (head && head.length > 0) upstream.write(head);
|
|
365
|
+
|
|
366
|
+
upstream.on('data', (chunk) => { stats.counters.bytesToClient += chunk.length; });
|
|
367
|
+
clientSocket.on('data', (chunk) => { stats.counters.bytesToUpstream += chunk.length; });
|
|
368
|
+
|
|
369
|
+
upstream.pipe(clientSocket);
|
|
370
|
+
clientSocket.pipe(upstream);
|
|
371
|
+
|
|
372
|
+
upstream.on('error', () => clientSocket.destroy());
|
|
373
|
+
clientSocket.on('error', () => upstream.destroy());
|
|
374
|
+
clientSocket.on('close', () => upstream.destroy());
|
|
375
|
+
})
|
|
376
|
+
.catch((err) => {
|
|
377
|
+
stats.counters.proxyErrors += 1;
|
|
378
|
+
log.warn(`CONNECT to ${target.hostname}:${target.port} failed: ${err.message}`);
|
|
379
|
+
if (!clientSocket.destroyed) {
|
|
380
|
+
clientSocket.end(err.code === 'ETIMEDOUT'
|
|
381
|
+
? 'HTTP/1.1 504 Gateway Timeout\r\n\r\n'
|
|
382
|
+
: 'HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Create and start an HTTP/HTTPS proxy server with DNS64 support.
|
|
389
|
+
*
|
|
390
|
+
* @param {number} [port] - Port to listen on
|
|
391
|
+
* @param {string} [host] - Interface to bind to (defaults to loopback)
|
|
392
|
+
* @returns {Promise<http.Server>} Resolves with the server once it's listening
|
|
393
|
+
*/
|
|
394
|
+
function createProxy(port = config.DEFAULT_PORT, host = config.BIND_HOST) {
|
|
395
|
+
return new Promise((resolve, reject) => {
|
|
396
|
+
const agent = new BridgeAgent();
|
|
397
|
+
const address = { host, port };
|
|
398
|
+
const server = http.createServer(createRequestHandler(agent, address));
|
|
399
|
+
const sockets = new Set();
|
|
400
|
+
|
|
401
|
+
server.on('connection', (socket) => {
|
|
402
|
+
sockets.add(socket);
|
|
403
|
+
socket.on('close', () => sockets.delete(socket));
|
|
129
404
|
});
|
|
130
405
|
|
|
131
|
-
server.on('
|
|
406
|
+
server.on('connect', handleConnect);
|
|
407
|
+
|
|
408
|
+
server.on('clientError', (err, socket) => {
|
|
409
|
+
if (!socket.writable) return;
|
|
410
|
+
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Close the server and tear down live connections.
|
|
415
|
+
*
|
|
416
|
+
* server.close() alone waits for every connection to end, and CONNECT
|
|
417
|
+
* tunnels are long-lived, so it would otherwise never resolve.
|
|
418
|
+
*/
|
|
419
|
+
server.closeGracefully = () => new Promise((done) => {
|
|
420
|
+
server.close(() => done());
|
|
421
|
+
agent.destroy();
|
|
422
|
+
for (const socket of sockets) socket.destroy();
|
|
423
|
+
sockets.clear();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
server.once('error', (err) => {
|
|
132
427
|
reject(new Error(`Failed to start proxy: ${err.message}`));
|
|
133
428
|
});
|
|
134
429
|
|
|
135
|
-
server.listen(port, () => {
|
|
430
|
+
server.listen(port, host, () => {
|
|
431
|
+
const bound = server.address();
|
|
432
|
+
address.port = bound.port;
|
|
433
|
+
log.info(`Proxy listening on ${bound.address}:${bound.port}`);
|
|
434
|
+
|
|
435
|
+
if (!config.isLoopbackBind(host)) {
|
|
436
|
+
if (config.AUTH || !config.ALLOW_FROM.isEmpty) {
|
|
437
|
+
log.info(`Proxy is reachable beyond loopback; access control is enabled.`);
|
|
438
|
+
} else {
|
|
439
|
+
log.warn(
|
|
440
|
+
`Proxy is bound to ${bound.address} with no authentication or allowlist. ` +
|
|
441
|
+
`Anyone who can reach this host can relay traffic through it. ` +
|
|
442
|
+
`Set IPV6_BRIDGE_AUTH or IPV6_BRIDGE_ALLOW.`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
136
446
|
resolve(server);
|
|
137
447
|
});
|
|
138
448
|
});
|
|
139
449
|
}
|
|
140
450
|
|
|
141
|
-
module.exports = {
|
|
451
|
+
module.exports = {
|
|
452
|
+
createProxy,
|
|
453
|
+
sanitizeHeaders,
|
|
454
|
+
parseRequestTarget,
|
|
455
|
+
parseAuthority,
|
|
456
|
+
checkAccess,
|
|
457
|
+
buildPacFile,
|
|
458
|
+
cidrToMask,
|
|
459
|
+
};
|