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/src/proxy.js CHANGED
@@ -1,141 +1,459 @@
1
1
  /**
2
2
  * IPv6 Bridge - HTTP/HTTPS Proxy
3
3
  *
4
- * Implements application-level NAT64 (RFC 6146) by intercepting HTTP/HTTPS
5
- * requests, resolving hostnames via DNS64, and routing through IPv6.
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 net = require('net');
12
- const { resolveIPv6, detectIPVersion } = require('./dns64');
13
- const { DEFAULT_PORT } = require('./config');
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
- * Resolve a target host to an IPv6 address if needed.
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 {string} hostname - The hostname or IP to resolve
19
- * @returns {Promise<{host: string, family: number}>} Resolved host and IP family
40
+ * @param {object} headers - Incoming headers
41
+ * @returns {object} Headers safe to forward
20
42
  */
21
- async function resolveTarget(hostname) {
22
- const ipVersion = detectIPVersion(hostname);
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
- if (ipVersion === 'ipv6') {
25
- return { host: hostname, family: 6 };
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
- // For both IPv4 addresses and hostnames, use DNS64 resolution
29
- // to get an IPv6 address with the NAT64 prefix.
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
- const ipv6Addresses = await resolveIPv6(hostname);
32
- if (ipv6Addresses && ipv6Addresses.length > 0) {
33
- return { host: ipv6Addresses[0], family: 6 };
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
- // Fall through to direct connection
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 { host: hostname, family: 4 };
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
- * Create an HTTP/HTTPS proxy server with NAT64 support.
121
+ * Constant-time-ish comparison for credentials.
44
122
  *
45
- * @param {number} port - Port to listen on (default: 8080)
46
- * @returns {Promise<http.Server>} Resolves with the server once it's listening
123
+ * @param {string} a - First value
124
+ * @param {string} b - Second value
125
+ * @returns {boolean} true if equal
47
126
  */
48
- function createProxy(port = DEFAULT_PORT) {
49
- return new Promise((resolve, reject) => {
50
- const server = http.createServer(async (req, res) => {
51
- try {
52
- const url = new URL(`http://${req.headers.host}${req.url}`);
53
- const hostname = url.hostname;
54
- const { host: targetHost, family: ipFamily } = await resolveTarget(hostname);
55
-
56
- const options = {
57
- hostname: targetHost,
58
- port: url.port || 80,
59
- path: url.pathname + url.search,
60
- method: req.method,
61
- headers: req.headers,
62
- family: ipFamily,
63
- timeout: 10000,
64
- };
65
-
66
- const proxy = http.request(options, (proxyRes) => {
67
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
68
- proxyRes.pipe(res);
69
- proxyRes.on('error', () => {
70
- if (!res.headersSent) {
71
- res.writeHead(502).end('Bad Gateway');
72
- }
73
- });
74
- });
75
-
76
- proxy.on('error', () => {
77
- if (!res.headersSent) {
78
- res.writeHead(502).end('Bad Gateway');
79
- }
80
- });
81
-
82
- proxy.on('timeout', () => {
83
- proxy.destroy();
84
- if (!res.headersSent) {
85
- res.writeHead(504).end('Gateway Timeout');
86
- }
87
- });
88
-
89
- req.pipe(proxy);
90
- req.on('error', () => proxy.destroy());
91
- } catch {
92
- if (!res.headersSent) {
93
- res.writeHead(500).end('Internal Server Error');
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
- // HTTPS CONNECT tunnel handler
99
- server.on('connect', async (req, socket, head) => {
100
- try {
101
- const [hostname, rawPort] = req.url.split(':');
102
- const targetPort = parseInt(rawPort, 10) || 443;
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
- if (targetPort < 1 || targetPort > 65535) {
105
- socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
106
- return;
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
- const { host: targetHost, family: ipFamily } = await resolveTarget(hostname);
110
-
111
- const conn = net.connect(
112
- { port: targetPort, host: targetHost, family: ipFamily },
113
- () => {
114
- socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
115
- conn.write(head);
116
- conn.pipe(socket).pipe(conn);
117
- }
118
- );
119
-
120
- conn.on('error', () => socket.end());
121
- socket.on('error', () => conn.end());
122
- conn.setTimeout(10000, () => {
123
- conn.destroy();
124
- socket.end();
125
- });
126
- } catch {
127
- socket.end();
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('error', (err) => {
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 = { createProxy };
451
+ module.exports = {
452
+ createProxy,
453
+ sanitizeHeaders,
454
+ parseRequestTarget,
455
+ parseAuthority,
456
+ checkAccess,
457
+ buildPacFile,
458
+ cidrToMask,
459
+ };