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/docs/ROADMAP.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Roadmap & Project Vision
|
|
2
|
+
|
|
3
|
+
**IPv6 Bridge** is a zero-dependency DNS64/NAT64 bridge for hosts stranded on
|
|
4
|
+
IPv6-only networks. This document tracks what has shipped and what is still
|
|
5
|
+
open.
|
|
6
|
+
|
|
7
|
+
## Shipped in 2.0
|
|
8
|
+
|
|
9
|
+
These were the Tier 1 and Tier 2 items on the previous roadmap.
|
|
10
|
+
|
|
11
|
+
- **DNS cache** — bounded LRU with TTL expiry, so repeat requests skip resolution.
|
|
12
|
+
- **Connection pooling & keep-alive** — upstream sockets are reused across requests.
|
|
13
|
+
- **Connection failover** — candidates are tried in preference order (native IPv6,
|
|
14
|
+
then NAT64, then direct IPv4) instead of failing on the first unreachable address.
|
|
15
|
+
- **Improved network detection** — IPv4 reachability is checked first, so the bridge
|
|
16
|
+
no longer activates on healthy dual-stack networks.
|
|
17
|
+
- **Custom NAT64 prefix formats** — every RFC 6052 prefix length (`/32`, `/40`,
|
|
18
|
+
`/48`, `/56`, `/64`, `/96`), validated against the RFC's own test vectors.
|
|
19
|
+
- **NAT64 prefix discovery (RFC 7050)** — the network's real prefix is discovered
|
|
20
|
+
from `ipv4only.arpa` rather than assumed.
|
|
21
|
+
- **SOCKS5 support** — ssh, git, databases and any other TCP protocol.
|
|
22
|
+
- **PAC (Proxy Auto-Configuration)** — served at `/proxy.pac`.
|
|
23
|
+
- **Per-domain routing policies** — `IPV6_BRIDGE_BYPASS` for split routing.
|
|
24
|
+
- **Authentication** — Basic credentials and a client CIDR allowlist, enforced on
|
|
25
|
+
the HTTP, CONNECT and SOCKS5 paths.
|
|
26
|
+
- **Observability** — `/healthz`, `/status` and Prometheus `/metrics`, including a
|
|
27
|
+
`translationRate` that shows whether translation is actually happening.
|
|
28
|
+
- **Diagnostics** — `ipv6-bridge doctor`.
|
|
29
|
+
|
|
30
|
+
## Open
|
|
31
|
+
|
|
32
|
+
### Reliability
|
|
33
|
+
|
|
34
|
+
- **Full Happy Eyeballs (RFC 8305)** — connection attempts are currently
|
|
35
|
+
sequential with a per-attempt timeout. True Happy Eyeballs races families with
|
|
36
|
+
a staggered delay, which lowers worst-case latency on partially broken networks.
|
|
37
|
+
- **Real DNS TTLs** — the system resolver does not expose them, so the cache uses
|
|
38
|
+
a fixed TTL. Honouring real TTLs would need a resolver that reports them without
|
|
39
|
+
reintroducing the `dns.resolve*` failure mode on DoH-only hosts.
|
|
40
|
+
- **Circuit breaking** — remember recently failed upstreams instead of retrying
|
|
41
|
+
every candidate on every request.
|
|
42
|
+
|
|
43
|
+
### Protocol coverage
|
|
44
|
+
|
|
45
|
+
- **HTTP/2 and HTTP/3 to the origin** — upstream requests are HTTP/1.1. This needs
|
|
46
|
+
a move away from Node's core `http` module for the upstream leg.
|
|
47
|
+
- **SOCKS5 UDP (`UDP ASSOCIATE`)** — would extend coverage to DNS, QUIC and
|
|
48
|
+
game traffic.
|
|
49
|
+
- **WebSocket** — works today inside a CONNECT tunnel, but not for plain-HTTP
|
|
50
|
+
`Upgrade` requests, which the proxy currently strips.
|
|
51
|
+
|
|
52
|
+
### Operations
|
|
53
|
+
|
|
54
|
+
- **Rate limiting** — per-client request and bandwidth caps for shared deployments.
|
|
55
|
+
- **Structured JSON logging** — for log aggregation pipelines.
|
|
56
|
+
- **Container image** — a published image with sensible defaults.
|
|
57
|
+
|
|
58
|
+
### Reach
|
|
59
|
+
|
|
60
|
+
- **Transparent interception** — the largest remaining adoption barrier is that
|
|
61
|
+
applications must be configured to use the proxy. A TUN-based mode would remove
|
|
62
|
+
that, at the cost of admin rights and platform-specific code, so it would need
|
|
63
|
+
to be an opt-in mode rather than a replacement for the user-space design.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
*Contributions are welcome! If you're interested in tackling any of these roadmap
|
|
68
|
+
items, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) and open an issue
|
|
69
|
+
to discuss the implementation plan.*
|
package/examples/basic-usage.js
CHANGED
|
@@ -5,34 +5,42 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Run:
|
|
7
7
|
* node examples/basic-usage.js
|
|
8
|
+
*
|
|
9
|
+
* The bridge only starts if this machine actually needs it. To try it on a
|
|
10
|
+
* dual-stack network, force it:
|
|
11
|
+
* FORCE_BRIDGE=1 node examples/basic-usage.js
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
const { start, stop } = require('../src/index');
|
|
11
15
|
|
|
12
16
|
async function main() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
console.log('Press Ctrl+C to stop.\n');
|
|
22
|
-
|
|
23
|
-
process.on('SIGINT', async () => {
|
|
24
|
-
console.log('\nShutting down...');
|
|
25
|
-
await stop();
|
|
26
|
-
process.exit(0);
|
|
27
|
-
});
|
|
28
|
-
} else {
|
|
29
|
-
console.log('Bridge not needed — you have IPv4 connectivity or working NAT64.');
|
|
30
|
-
process.exit(0);
|
|
31
|
-
}
|
|
32
|
-
} catch (error) {
|
|
33
|
-
console.error('Error:', error.message);
|
|
34
|
-
process.exit(1);
|
|
17
|
+
console.log('Starting IPv6 Bridge...\n');
|
|
18
|
+
|
|
19
|
+
const server = await start(8080);
|
|
20
|
+
|
|
21
|
+
if (!server) {
|
|
22
|
+
console.log('Bridge not needed — IPv4 is reachable, or NAT64 already works.');
|
|
23
|
+
console.log('Run with FORCE_BRIDGE=1 to start it anyway.');
|
|
24
|
+
return;
|
|
35
25
|
}
|
|
26
|
+
|
|
27
|
+
const { port } = server.address();
|
|
28
|
+
console.log(`Bridge started on port ${port}`);
|
|
29
|
+
console.log(`Configure your browser/system proxy to 127.0.0.1:${port}`);
|
|
30
|
+
console.log(`Check what it is doing: http://127.0.0.1:${port}/status\n`);
|
|
31
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
32
|
+
|
|
33
|
+
const shutdown = async () => {
|
|
34
|
+
console.log('\nShutting down...');
|
|
35
|
+
await stop();
|
|
36
|
+
process.exit(0);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
process.on('SIGINT', shutdown);
|
|
40
|
+
process.on('SIGTERM', shutdown);
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
main()
|
|
43
|
+
main().catch((error) => {
|
|
44
|
+
console.error('Error:', error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge — Embedded Usage Example
|
|
3
|
+
*
|
|
4
|
+
* Using the bridge from inside an application: no proxy, no ports, no system
|
|
5
|
+
* configuration. Outbound connections gain DNS64 translation, address-family
|
|
6
|
+
* failover and connection pooling.
|
|
7
|
+
*
|
|
8
|
+
* This is the way to use the package in a deployed service.
|
|
9
|
+
*
|
|
10
|
+
* Run:
|
|
11
|
+
* node examples/embedded-usage.js
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const https = require('https');
|
|
15
|
+
const net = require('net');
|
|
16
|
+
const {
|
|
17
|
+
createHttpsAgent,
|
|
18
|
+
createLookup,
|
|
19
|
+
resolve,
|
|
20
|
+
getStats,
|
|
21
|
+
} = require('../src/index');
|
|
22
|
+
|
|
23
|
+
// One agent for the lifetime of the process: it pools connections, so creating
|
|
24
|
+
// a new one per request would throw that away.
|
|
25
|
+
const agent = createHttpsAgent();
|
|
26
|
+
|
|
27
|
+
function get(url) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const options = { agent, headers: { 'User-Agent': 'ipv6-bridge-example' } };
|
|
30
|
+
const req = https.get(url, options, (res) => {
|
|
31
|
+
const via = res.socket.remoteAddress;
|
|
32
|
+
res.resume();
|
|
33
|
+
res.on('end', () => resolve({ status: res.statusCode, via }));
|
|
34
|
+
});
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
req.setTimeout(15000, () => {
|
|
37
|
+
req.destroy();
|
|
38
|
+
reject(new Error('request timed out'));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
console.log('1. Which route would be used for each destination?\n');
|
|
45
|
+
for (const host of ['example.com', '8.8.8.8', '192.168.1.1']) {
|
|
46
|
+
const candidates = await resolve(host);
|
|
47
|
+
const preferred = candidates[0];
|
|
48
|
+
console.log(` ${host.padEnd(16)} ${preferred.host} (${preferred.mode})`);
|
|
49
|
+
if (candidates.length > 1) {
|
|
50
|
+
console.log(` ${''.padEnd(16)} fallbacks: ${candidates.slice(1).map((c) => c.mode).join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('\n2. Making real requests through the agent\n');
|
|
55
|
+
for (const url of ['https://example.com/', 'https://api.github.com/']) {
|
|
56
|
+
try {
|
|
57
|
+
const { status, via } = await get(url);
|
|
58
|
+
console.log(` ${url.padEnd(28)} ${status} via ${via}`);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.log(` ${url.padEnd(28)} failed: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log('\n3. Using the lookup function with a raw socket\n');
|
|
65
|
+
await new Promise((resolve) => {
|
|
66
|
+
const socket = net.connect({ host: 'example.com', port: 443, lookup: createLookup() }, () => {
|
|
67
|
+
console.log(` connected to example.com:443 via ${socket.remoteAddress}`);
|
|
68
|
+
socket.destroy();
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
socket.on('error', (err) => {
|
|
72
|
+
console.log(` connection failed: ${err.message}`);
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
console.log('\n4. Did translation actually happen?\n');
|
|
78
|
+
const stats = getStats();
|
|
79
|
+
console.log(` NAT64 prefix ${stats.nat64Prefix}`);
|
|
80
|
+
console.log(` translated ${stats.routes.nat64}`);
|
|
81
|
+
console.log(` native IPv6 ${stats.routes.nativeIpv6}`);
|
|
82
|
+
console.log(` untranslated ${stats.routes.directIpv4 + stats.routes.directIpv4Fallback}`);
|
|
83
|
+
console.log(` translation rate ${stats.translationRate ?? 'n/a'}`);
|
|
84
|
+
console.log(` DNS cache hit rate ${stats.dnsCache.hitRate}`);
|
|
85
|
+
|
|
86
|
+
if (stats.routes.directIpv4Fallback > 0 && stats.routes.nat64 === 0) {
|
|
87
|
+
console.log('\n WARNING: connections fell back to untranslated IPv4.');
|
|
88
|
+
console.log(' On an IPv6-only network this means DNS64 is failing.');
|
|
89
|
+
console.log(' Run "ipv6-bridge doctor" to find out why.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Release pooled sockets so the process can exit.
|
|
93
|
+
agent.destroy();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch((err) => {
|
|
97
|
+
console.error('Error:', err.message);
|
|
98
|
+
agent.destroy();
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge — Production Usage Example
|
|
3
|
+
*
|
|
4
|
+
* Shows the pieces that matter when the bridge is more than a local
|
|
5
|
+
* convenience: access control, SOCKS5 for non-HTTP traffic, prefix discovery,
|
|
6
|
+
* health monitoring and a clean shutdown.
|
|
7
|
+
*
|
|
8
|
+
* Run:
|
|
9
|
+
* FORCE_BRIDGE=1 node examples/production-usage.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const { start, stop } = require('../src/index');
|
|
14
|
+
|
|
15
|
+
const PROXY_PORT = Number(process.env.IPV6_BRIDGE_PORT) || 8080;
|
|
16
|
+
const SOCKS_PORT = Number(process.env.IPV6_BRIDGE_SOCKS_PORT) || 1080;
|
|
17
|
+
|
|
18
|
+
// Access control must be configured before the bridge is required, because
|
|
19
|
+
// configuration is read and validated at load time.
|
|
20
|
+
if (!process.env.IPV6_BRIDGE_HOST) {
|
|
21
|
+
// Loopback is the default; set IPV6_BRIDGE_HOST to expose the proxy, and
|
|
22
|
+
// always pair that with IPV6_BRIDGE_AUTH or IPV6_BRIDGE_ALLOW.
|
|
23
|
+
process.env.IPV6_BRIDGE_HOST = '127.0.0.1';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Poll /status and report whether translation is actually happening. */
|
|
27
|
+
function fetchStatus(port) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/status' }, (res) => {
|
|
30
|
+
let body = '';
|
|
31
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
32
|
+
res.on('end', () => {
|
|
33
|
+
try {
|
|
34
|
+
resolve(JSON.parse(body));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
reject(err);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
req.setTimeout(3000, () => {
|
|
42
|
+
req.destroy();
|
|
43
|
+
reject(new Error('status request timed out'));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const server = await start(PROXY_PORT, {
|
|
50
|
+
// Run RFC 7050 discovery so the network's real NAT64 prefix is used.
|
|
51
|
+
discoverPrefix: true,
|
|
52
|
+
// Serve SOCKS5 as well, for ssh/git/database clients.
|
|
53
|
+
socksPort: SOCKS_PORT,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!server) {
|
|
57
|
+
console.log('Bridge not needed on this network.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { port } = server.address();
|
|
62
|
+
console.log(`Proxy: http://127.0.0.1:${port}`);
|
|
63
|
+
console.log(`SOCKS5: 127.0.0.1:${SOCKS_PORT}`);
|
|
64
|
+
console.log(`Health: http://127.0.0.1:${port}/healthz`);
|
|
65
|
+
console.log(`Metrics: http://127.0.0.1:${port}/metrics\n`);
|
|
66
|
+
|
|
67
|
+
// Periodically check that the bridge is doing what it claims. A translation
|
|
68
|
+
// rate of zero alongside rising fallbacks means DNS64 is failing.
|
|
69
|
+
const monitor = setInterval(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const status = await fetchStatus(port);
|
|
72
|
+
const { nat64, directIpv4Fallback } = status.routes;
|
|
73
|
+
|
|
74
|
+
if (directIpv4Fallback > 0 && nat64 === 0) {
|
|
75
|
+
console.warn(
|
|
76
|
+
`[warn] ${directIpv4Fallback} connection(s) fell back to untranslated IPv4 ` +
|
|
77
|
+
`and none were translated. Run "ipv6-bridge doctor" to find out why.`
|
|
78
|
+
);
|
|
79
|
+
} else {
|
|
80
|
+
console.log(
|
|
81
|
+
`[ok] translated=${nat64} fallback=${directIpv4Fallback} ` +
|
|
82
|
+
`rate=${status.translationRate ?? 'n/a'} ` +
|
|
83
|
+
`cache=${status.dnsCache.hitRate}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`[error] status check failed: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}, 15000);
|
|
90
|
+
|
|
91
|
+
const shutdown = async (signal) => {
|
|
92
|
+
console.log(`\nReceived ${signal}, shutting down...`);
|
|
93
|
+
clearInterval(monitor);
|
|
94
|
+
await stop();
|
|
95
|
+
console.log('Stopped cleanly.');
|
|
96
|
+
process.exit(0);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
100
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
main().catch((error) => {
|
|
104
|
+
console.error('Failed to start:', error.message);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ipv6-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.1",
|
|
4
|
+
"description": "DNS64-aware HTTP proxy for IPv6-only networks — access IPv4 sites from IPv6-only environments",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node src/cli.js start",
|
|
12
|
-
"test": "node --test
|
|
13
|
-
"demo": "
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"demo": "node demo-app/server.js",
|
|
14
14
|
"prepublishOnly": "npm test"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"files": [
|
|
43
43
|
"src/",
|
|
44
44
|
"examples/",
|
|
45
|
+
"docs/",
|
|
45
46
|
"LICENSE",
|
|
46
|
-
"README.md"
|
|
47
|
-
"CHANGELOG.md"
|
|
47
|
+
"README.md"
|
|
48
48
|
],
|
|
49
49
|
"dependencies": {}
|
|
50
50
|
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Embeddable connection primitives
|
|
3
|
+
*
|
|
4
|
+
* Everything here lets an application use DNS64/NAT64 translation directly,
|
|
5
|
+
* without running a proxy or changing any system configuration. Outbound
|
|
6
|
+
* connections are made over native IPv6 where possible, through a synthesized
|
|
7
|
+
* NAT64 address where translation is needed, and over IPv4 as a last resort.
|
|
8
|
+
*
|
|
9
|
+
* None of this can manufacture connectivity the host does not have. It makes
|
|
10
|
+
* the host reach everything it *can* reach, regardless of address family.
|
|
11
|
+
*
|
|
12
|
+
* @module agent
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const http = require('http');
|
|
16
|
+
const https = require('https');
|
|
17
|
+
const tls = require('tls');
|
|
18
|
+
const { connectWithFallback } = require('./connect');
|
|
19
|
+
const { resolveCandidates } = require('./dns64');
|
|
20
|
+
const config = require('./config');
|
|
21
|
+
|
|
22
|
+
function shouldBypass(hostname) {
|
|
23
|
+
return !config.BYPASS.isEmpty && config.BYPASS.matches(hostname);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Open a bridged TCP connection for an agent.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options - Connection options from the agent
|
|
30
|
+
* @param {number} defaultPort - Port to use when none is given
|
|
31
|
+
* @returns {Promise<net.Socket>} Connected socket
|
|
32
|
+
*/
|
|
33
|
+
function openSocket(options, defaultPort) {
|
|
34
|
+
const hostname = options.host;
|
|
35
|
+
const port = Number(options.port) || defaultPort;
|
|
36
|
+
return connectWithFallback(hostname, port, { bypass: shouldBypass(hostname) })
|
|
37
|
+
.then(({ socket }) => socket);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Layer TLS onto an already-connected plaintext socket, reporting success or
|
|
42
|
+
* failure through a Node-style callback.
|
|
43
|
+
*
|
|
44
|
+
* tls.connect() can throw synchronously — a caller-supplied option such as an
|
|
45
|
+
* invalid secureProtocol or cipher list fails while it builds the security
|
|
46
|
+
* context, before any socket event fires. Left uncaught, that throw would
|
|
47
|
+
* escape as an uncaught exception and crash the embedding host application on
|
|
48
|
+
* its very first request, which is a much worse outcome than the one failed
|
|
49
|
+
* connection a caught error produces.
|
|
50
|
+
*
|
|
51
|
+
* @param {net.Socket} socket - Connected plaintext socket to upgrade
|
|
52
|
+
* @param {object} tlsOptions - Options for tls.connect
|
|
53
|
+
* @param {string} hostname - Hostname to validate the certificate against
|
|
54
|
+
* @param {(err: Error|null, socket?: tls.TLSSocket) => void} callback - Result callback
|
|
55
|
+
*/
|
|
56
|
+
function upgradeToTls(socket, tlsOptions, hostname, callback) {
|
|
57
|
+
let secure;
|
|
58
|
+
try {
|
|
59
|
+
secure = tls.connect({
|
|
60
|
+
...tlsOptions,
|
|
61
|
+
socket,
|
|
62
|
+
// SNI and certificate identity must use the real hostname, never the
|
|
63
|
+
// synthesized address the plaintext socket was dialled through.
|
|
64
|
+
host: hostname,
|
|
65
|
+
servername: tlsOptions.servername || hostname,
|
|
66
|
+
});
|
|
67
|
+
} catch (err) {
|
|
68
|
+
socket.destroy();
|
|
69
|
+
callback(err);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const onError = (err) => {
|
|
74
|
+
socket.destroy();
|
|
75
|
+
callback(err);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
secure.once('error', onError);
|
|
79
|
+
secure.once('secureConnect', () => {
|
|
80
|
+
secure.removeListener('error', onError);
|
|
81
|
+
callback(null, secure);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HTTP agent that resolves through DNS64 and pools the resulting sockets.
|
|
87
|
+
*
|
|
88
|
+
* Sockets are keyed by the original hostname, so pooling is unaffected by the
|
|
89
|
+
* fact that the address actually dialled may be synthesized.
|
|
90
|
+
*/
|
|
91
|
+
class BridgeHttpAgent extends http.Agent {
|
|
92
|
+
constructor(options = {}) {
|
|
93
|
+
super({
|
|
94
|
+
keepAlive: true,
|
|
95
|
+
keepAliveMsecs: config.KEEP_ALIVE_MS,
|
|
96
|
+
maxSockets: config.MAX_SOCKETS_PER_HOST,
|
|
97
|
+
timeout: config.CONNECTION_TIMEOUT,
|
|
98
|
+
...options,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
createConnection(options, callback) {
|
|
103
|
+
openSocket(options, 80).then(
|
|
104
|
+
(socket) => callback(null, socket),
|
|
105
|
+
(err) => callback(err)
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* HTTPS agent that performs the TLS handshake over a bridged socket.
|
|
112
|
+
*
|
|
113
|
+
* The certificate is validated against the original hostname, never against
|
|
114
|
+
* the synthesized address it was reached through — otherwise validation could
|
|
115
|
+
* never succeed and users would be pushed into disabling it.
|
|
116
|
+
*/
|
|
117
|
+
class BridgeHttpsAgent extends https.Agent {
|
|
118
|
+
constructor(options = {}) {
|
|
119
|
+
super({
|
|
120
|
+
keepAlive: true,
|
|
121
|
+
keepAliveMsecs: config.KEEP_ALIVE_MS,
|
|
122
|
+
maxSockets: config.MAX_SOCKETS_PER_HOST,
|
|
123
|
+
timeout: config.CONNECTION_TIMEOUT,
|
|
124
|
+
...options,
|
|
125
|
+
});
|
|
126
|
+
this.tlsOptions = options;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
createConnection(options, callback) {
|
|
130
|
+
const hostname = options.host;
|
|
131
|
+
|
|
132
|
+
openSocket(options, 443).then(
|
|
133
|
+
(socket) => upgradeToTls(socket, { ...this.tlsOptions, ...options }, hostname, callback),
|
|
134
|
+
(err) => callback(err)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create an HTTP agent backed by the bridge.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} [options] - http.Agent options
|
|
143
|
+
* @returns {http.Agent} Agent for use with http.request / axios / got
|
|
144
|
+
*/
|
|
145
|
+
function createAgent(options) {
|
|
146
|
+
return new BridgeHttpAgent(options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create an HTTPS agent backed by the bridge.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} [options] - https.Agent options
|
|
153
|
+
* @returns {https.Agent} Agent for use with https.request / axios / got
|
|
154
|
+
*/
|
|
155
|
+
function createHttpsAgent(options) {
|
|
156
|
+
return new BridgeHttpsAgent(options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create agents for both protocols.
|
|
161
|
+
*
|
|
162
|
+
* @param {object} [options] - Agent options applied to both
|
|
163
|
+
* @returns {{http: http.Agent, https: https.Agent}} Agents by protocol
|
|
164
|
+
*/
|
|
165
|
+
function createAgents(options) {
|
|
166
|
+
return {
|
|
167
|
+
http: createAgent(options),
|
|
168
|
+
https: createHttpsAgent(options),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a dns.lookup-compatible function that applies DNS64 synthesis.
|
|
174
|
+
*
|
|
175
|
+
* Anything accepting a `lookup` option — net.connect, http.request, most
|
|
176
|
+
* client libraries — can use this without further changes. Matches the real
|
|
177
|
+
* dns.lookup contract: `options` may be omitted, a plain object, or (per
|
|
178
|
+
* Node's documented shorthand) an integer meaning the requested address
|
|
179
|
+
* family.
|
|
180
|
+
*
|
|
181
|
+
* @returns {Function} Function with the dns.lookup signature
|
|
182
|
+
*/
|
|
183
|
+
function createLookup() {
|
|
184
|
+
return function bridgeLookup(hostname, options, callback) {
|
|
185
|
+
if (typeof options === 'function') {
|
|
186
|
+
callback = options;
|
|
187
|
+
options = {};
|
|
188
|
+
} else if (typeof options === 'number') {
|
|
189
|
+
options = { family: options };
|
|
190
|
+
} else {
|
|
191
|
+
options = options || {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
resolveCandidates(hostname).then((candidates) => {
|
|
195
|
+
const matching = options.family
|
|
196
|
+
? candidates.filter((c) => c.family === options.family)
|
|
197
|
+
: candidates;
|
|
198
|
+
|
|
199
|
+
if (options.family && matching.length === 0) {
|
|
200
|
+
const err = new Error(
|
|
201
|
+
`No family ${options.family} address available for ${hostname}`
|
|
202
|
+
);
|
|
203
|
+
err.code = 'EAI_ADDRFAMILY';
|
|
204
|
+
callback(err);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const selected = matching.length > 0 ? matching : candidates;
|
|
209
|
+
|
|
210
|
+
if (options.all) {
|
|
211
|
+
callback(null, selected.map((c) => ({ address: c.host, family: c.family })));
|
|
212
|
+
} else {
|
|
213
|
+
callback(null, selected[0].host, selected[0].family);
|
|
214
|
+
}
|
|
215
|
+
}, (err) => callback(err));
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create a connector for undici (and therefore Node's global fetch).
|
|
221
|
+
*
|
|
222
|
+
* undici is not a dependency of this package. Applications that already use it
|
|
223
|
+
* can wire the bridge in:
|
|
224
|
+
*
|
|
225
|
+
* const { Agent, setGlobalDispatcher } = require('undici');
|
|
226
|
+
* const { createConnector } = require('ipv6-bridge');
|
|
227
|
+
* setGlobalDispatcher(new Agent({ connect: createConnector() }));
|
|
228
|
+
*
|
|
229
|
+
* @returns {Function} Function matching undici's `connect` option
|
|
230
|
+
*/
|
|
231
|
+
function createConnector() {
|
|
232
|
+
return function bridgeConnect(options, callback) {
|
|
233
|
+
const { hostname, port, protocol } = options;
|
|
234
|
+
const isSecure = protocol === 'https:';
|
|
235
|
+
const targetPort = Number(port) || (isSecure ? 443 : 80);
|
|
236
|
+
|
|
237
|
+
connectWithFallback(hostname, targetPort, { bypass: shouldBypass(hostname) }).then(
|
|
238
|
+
({ socket }) => {
|
|
239
|
+
if (!isSecure) {
|
|
240
|
+
callback(null, socket);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
upgradeToTls(socket, options, hostname, callback);
|
|
244
|
+
},
|
|
245
|
+
(err) => callback(err)
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve a hostname the way the bridge would, without connecting.
|
|
252
|
+
*
|
|
253
|
+
* Useful for logging or asserting in tests which route would be taken.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} hostname - Hostname or IP literal
|
|
256
|
+
* @returns {Promise<Array<{host: string, family: number, mode: string}>>} Candidates
|
|
257
|
+
*/
|
|
258
|
+
function resolve(hostname) {
|
|
259
|
+
return resolveCandidates(hostname);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
createAgent,
|
|
264
|
+
createHttpsAgent,
|
|
265
|
+
createAgents,
|
|
266
|
+
createLookup,
|
|
267
|
+
createConnector,
|
|
268
|
+
resolve,
|
|
269
|
+
BridgeHttpAgent,
|
|
270
|
+
BridgeHttpsAgent,
|
|
271
|
+
};
|