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/connect.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Outbound connection handling
|
|
3
|
+
*
|
|
4
|
+
* Establishes upstream connections with failover across the candidate
|
|
5
|
+
* addresses produced by DNS64, and provides a pooling HTTP agent so repeated
|
|
6
|
+
* requests to the same host reuse sockets.
|
|
7
|
+
*
|
|
8
|
+
* @module connect
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const net = require('net');
|
|
13
|
+
const { resolveCandidates } = require('./dns64');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
const stats = require('./stats');
|
|
16
|
+
const log = require('./logger');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Open a TCP connection to one candidate address.
|
|
20
|
+
*
|
|
21
|
+
* @param {{host: string, family: number}} candidate - Address to try
|
|
22
|
+
* @param {number} port - Destination port
|
|
23
|
+
* @param {number} timeout - Per-attempt timeout in milliseconds
|
|
24
|
+
* @returns {Promise<net.Socket>} Connected socket
|
|
25
|
+
*/
|
|
26
|
+
function attempt(candidate, port, timeout) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const socket = net.connect({
|
|
29
|
+
host: candidate.host,
|
|
30
|
+
port,
|
|
31
|
+
family: candidate.family,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const fail = (err) => {
|
|
35
|
+
socket.destroy();
|
|
36
|
+
reject(err);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
socket.setTimeout(timeout, () => {
|
|
40
|
+
fail(Object.assign(new Error(
|
|
41
|
+
`Connection to ${candidate.host}:${port} timed out after ${timeout}ms`
|
|
42
|
+
), { code: 'ETIMEDOUT' }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
socket.once('error', fail);
|
|
46
|
+
|
|
47
|
+
socket.once('connect', () => {
|
|
48
|
+
socket.setTimeout(0);
|
|
49
|
+
socket.removeListener('error', fail);
|
|
50
|
+
resolve(socket);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Connect to a host, trying each candidate address in preference order.
|
|
57
|
+
*
|
|
58
|
+
* A single unreachable address is the normal case on a partially broken
|
|
59
|
+
* network, so failing on the first attempt would make the bridge far less
|
|
60
|
+
* reliable than the stack it replaces.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} hostname - Target hostname or IP literal
|
|
63
|
+
* @param {number} port - Destination port
|
|
64
|
+
* @param {object} [options] - Options
|
|
65
|
+
* @param {boolean} [options.bypass] - Connect directly, skipping NAT64
|
|
66
|
+
* @returns {Promise<{socket: net.Socket, candidate: object}>} Connected socket and the candidate used
|
|
67
|
+
*/
|
|
68
|
+
async function connectWithFallback(hostname, port, { bypass = false } = {}) {
|
|
69
|
+
const candidates = bypass
|
|
70
|
+
? [{ host: hostname, family: 0, mode: 'bypassed' }]
|
|
71
|
+
: await resolveCandidates(hostname);
|
|
72
|
+
|
|
73
|
+
const attemptTimeout = Math.min(
|
|
74
|
+
config.CONNECT_ATTEMPT_TIMEOUT,
|
|
75
|
+
config.CONNECTION_TIMEOUT
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const failures = [];
|
|
79
|
+
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
try {
|
|
82
|
+
const socket = await attempt(candidate, port, attemptTimeout);
|
|
83
|
+
|
|
84
|
+
if (failures.length > 0) {
|
|
85
|
+
log.debug(
|
|
86
|
+
`Connected to ${hostname}:${port} via ${candidate.host} (${candidate.mode}) ` +
|
|
87
|
+
`after ${failures.length} failed candidate(s)`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (candidate.mode === 'direct-ipv4' && candidates.some((c) => c.mode === 'nat64')) {
|
|
92
|
+
log.warn(
|
|
93
|
+
`NAT64 route to ${hostname} failed; connected directly over IPv4 instead. ` +
|
|
94
|
+
`This request is NOT being translated.`
|
|
95
|
+
);
|
|
96
|
+
stats.recordRoute('direct-ipv4-fallback');
|
|
97
|
+
} else {
|
|
98
|
+
stats.recordRoute(candidate.mode);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { socket, candidate };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
failures.push(`${candidate.host} (${err.code || err.message})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const error = new Error(
|
|
108
|
+
`Unable to connect to ${hostname}:${port}; tried ${failures.join(', ')}`
|
|
109
|
+
);
|
|
110
|
+
error.code = 'EHOSTUNREACH';
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* HTTP agent that resolves through DNS64 and pools the resulting sockets.
|
|
116
|
+
*
|
|
117
|
+
* Sockets are keyed by the original hostname, so pooling survives the fact
|
|
118
|
+
* that the address the bridge dials is synthesized rather than literal.
|
|
119
|
+
*/
|
|
120
|
+
class BridgeAgent extends http.Agent {
|
|
121
|
+
constructor(options = {}) {
|
|
122
|
+
super({
|
|
123
|
+
keepAlive: true,
|
|
124
|
+
keepAliveMsecs: config.KEEP_ALIVE_MS,
|
|
125
|
+
maxSockets: config.MAX_SOCKETS_PER_HOST,
|
|
126
|
+
timeout: config.CONNECTION_TIMEOUT,
|
|
127
|
+
...options,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
createConnection(options, callback) {
|
|
132
|
+
const hostname = options.host;
|
|
133
|
+
const port = Number(options.port) || 80;
|
|
134
|
+
const bypass = !config.BYPASS.isEmpty && config.BYPASS.matches(hostname);
|
|
135
|
+
|
|
136
|
+
connectWithFallback(hostname, port, { bypass })
|
|
137
|
+
.then(({ socket }) => callback(null, socket))
|
|
138
|
+
.catch((err) => callback(err));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { connectWithFallback, BridgeAgent, attempt };
|
package/src/detect.js
CHANGED
|
@@ -1,77 +1,116 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IPv6 Bridge - Network Detection
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Decides whether the bridge is needed. The bridge only helps a host that has
|
|
5
|
+
* IPv6 but cannot reach IPv4-only servers, so detection has to establish both
|
|
6
|
+
* facts before reporting that it is needed.
|
|
6
7
|
*
|
|
7
8
|
* @module detect
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const http = require('http');
|
|
11
12
|
const { resolveIPv6 } = require('./dns64');
|
|
12
|
-
const
|
|
13
|
+
const config = require('./config');
|
|
14
|
+
const log = require('./logger');
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
IPV6_TEST_URL,
|
|
18
|
+
IPV4_TEST_URL,
|
|
19
|
+
NAT64_TEST_HOST,
|
|
20
|
+
DNS_TIMEOUT,
|
|
21
|
+
} = config;
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
*
|
|
24
|
+
* Probe a URL over a specific IP family.
|
|
16
25
|
*
|
|
17
|
-
*
|
|
26
|
+
* Any 2xx or 3xx response counts as reachable; requiring exactly 200 would
|
|
27
|
+
* misreport a network as broken the moment the endpoint starts redirecting.
|
|
18
28
|
*
|
|
19
|
-
* @
|
|
29
|
+
* @param {string} url - URL to request
|
|
30
|
+
* @param {number} family - IP family (4 or 6)
|
|
31
|
+
* @returns {Promise<boolean>} true if the endpoint responded
|
|
20
32
|
*/
|
|
21
|
-
|
|
33
|
+
function probe(url, family) {
|
|
22
34
|
return new Promise((resolve) => {
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
let settled = false;
|
|
36
|
+
const finish = (result) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
39
|
+
resolve(result);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const req = http.get(url, { family }, (res) => {
|
|
25
43
|
res.resume();
|
|
26
|
-
|
|
44
|
+
finish(res.statusCode >= 200 && res.statusCode < 400);
|
|
27
45
|
});
|
|
28
|
-
|
|
29
|
-
req.
|
|
46
|
+
|
|
47
|
+
req.on('error', () => finish(false));
|
|
48
|
+
req.setTimeout(DNS_TIMEOUT, () => {
|
|
30
49
|
req.destroy();
|
|
31
|
-
|
|
50
|
+
finish(false);
|
|
32
51
|
});
|
|
33
52
|
});
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/**
|
|
37
|
-
*
|
|
56
|
+
* Test whether the network has working IPv6 connectivity.
|
|
57
|
+
*
|
|
58
|
+
* @returns {Promise<boolean>} true if IPv6 is available
|
|
59
|
+
*/
|
|
60
|
+
function hasIPv6() {
|
|
61
|
+
return probe(IPV6_TEST_URL, 6);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Test whether the network has working IPv4 connectivity.
|
|
38
66
|
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
* @returns {Promise<boolean>} true if IPv4 is available
|
|
68
|
+
*/
|
|
69
|
+
function hasIPv4() {
|
|
70
|
+
return probe(IPV4_TEST_URL, 4);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Test whether an upstream NAT64 gateway is already translating traffic.
|
|
42
75
|
*
|
|
43
|
-
* @returns {Promise<boolean>} true if
|
|
76
|
+
* @returns {Promise<boolean>} true if NAT64 works without the bridge
|
|
77
|
+
*/
|
|
78
|
+
async function hasWorkingNAT64() {
|
|
79
|
+
try {
|
|
80
|
+
const addresses = await resolveIPv6(NAT64_TEST_HOST);
|
|
81
|
+
if (!addresses || addresses.length === 0) return false;
|
|
82
|
+
return await probe(`http://[${addresses[0]}]`, 6);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Determine whether the bridge is needed.
|
|
90
|
+
*
|
|
91
|
+
* The bridge is needed only when IPv4 is unreachable, IPv6 works, and the
|
|
92
|
+
* network provides no NAT64 gateway of its own.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<boolean>} true if the bridge is needed
|
|
44
95
|
*/
|
|
45
96
|
async function needsBridge() {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// No IPv6 means we're on IPv4 or a broken network.
|
|
49
|
-
// Either way, the bridge can't help.
|
|
97
|
+
if (await hasIPv4()) {
|
|
98
|
+
log.debug('IPv4 connectivity works; bridge is not needed');
|
|
50
99
|
return false;
|
|
51
100
|
}
|
|
52
101
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
102
|
+
if (!await hasIPv6()) {
|
|
103
|
+
log.debug('Neither IPv4 nor IPv6 connectivity works; the bridge cannot help');
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
58
106
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
const req = http.get(`http://[${ipv6[0]}]`, { family: 6 }, (res) => {
|
|
63
|
-
res.resume();
|
|
64
|
-
resolve(res.statusCode !== 200);
|
|
65
|
-
});
|
|
66
|
-
req.on('error', () => resolve(true));
|
|
67
|
-
req.setTimeout(5000, () => {
|
|
68
|
-
req.destroy();
|
|
69
|
-
resolve(true);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
} catch {
|
|
73
|
-
return true;
|
|
107
|
+
if (await hasWorkingNAT64()) {
|
|
108
|
+
log.debug('Upstream NAT64 gateway is already working; bridge is not needed');
|
|
109
|
+
return false;
|
|
74
110
|
}
|
|
111
|
+
|
|
112
|
+
log.debug('IPv6-only network with no working NAT64; bridge is needed');
|
|
113
|
+
return true;
|
|
75
114
|
}
|
|
76
115
|
|
|
77
|
-
module.exports = { hasIPv6, needsBridge };
|
|
116
|
+
module.exports = { hasIPv6, hasIPv4, hasWorkingNAT64, needsBridge };
|
package/src/discovery.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - NAT64 prefix discovery (RFC 7050)
|
|
3
|
+
*
|
|
4
|
+
* Networks that provide NAT64 rarely use the well-known prefix; most operators
|
|
5
|
+
* assign their own. RFC 7050 defines how to find it: resolve AAAA records for
|
|
6
|
+
* the special name "ipv4only.arpa", whose only real records are the two IPv4
|
|
7
|
+
* addresses below. A DNS64 resolver synthesizes AAAA records for that name, so
|
|
8
|
+
* whatever wraps those known IPv4 addresses is the network's NAT64 prefix.
|
|
9
|
+
*
|
|
10
|
+
* @module discovery
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { lookupAll } = require('./dns64');
|
|
14
|
+
const { parseIPv6, formatIPv6, extractIPv4, VALID_PREFIX_LENGTHS } = require('./ipv6');
|
|
15
|
+
const config = require('./config');
|
|
16
|
+
const log = require('./logger');
|
|
17
|
+
|
|
18
|
+
/** The well-known name and its fixed IPv4 addresses (RFC 7050 section 3). */
|
|
19
|
+
const DISCOVERY_NAME = 'ipv4only.arpa';
|
|
20
|
+
const WELL_KNOWN_IPV4 = ['192.0.0.170', '192.0.0.171'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derive the NAT64 prefix from a synthesized IPv4-embedded address.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} address - Synthesized IPv6 address
|
|
26
|
+
* @returns {{prefix: string, length: number, bytes: Buffer}|null} Prefix, or null if no match
|
|
27
|
+
*/
|
|
28
|
+
function prefixFromSynthesized(address) {
|
|
29
|
+
for (const length of VALID_PREFIX_LENGTHS) {
|
|
30
|
+
const embedded = extractIPv4(address, length);
|
|
31
|
+
if (!embedded || !WELL_KNOWN_IPV4.includes(embedded)) continue;
|
|
32
|
+
|
|
33
|
+
const bytes = parseIPv6(address);
|
|
34
|
+
if (!bytes) continue;
|
|
35
|
+
|
|
36
|
+
// Zero everything after the prefix to get the prefix itself.
|
|
37
|
+
const prefixBytes = Buffer.from(bytes);
|
|
38
|
+
for (let bit = length; bit < 128; bit++) {
|
|
39
|
+
const index = Math.floor(bit / 8);
|
|
40
|
+
prefixBytes[index] &= ~(1 << (7 - (bit % 8))) & 0xff;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { prefix: formatIPv6(prefixBytes), length, bytes: prefixBytes };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Discover the NAT64 prefix this network uses.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} [options] - Options
|
|
53
|
+
* @param {number} [options.timeout] - DNS timeout in milliseconds
|
|
54
|
+
* @returns {Promise<{prefix: string, length: number, bytes: Buffer, source: string}|null>}
|
|
55
|
+
* The discovered prefix, or null if the network provides no DNS64 resolver
|
|
56
|
+
*/
|
|
57
|
+
async function discoverPrefix({ timeout = config.DNS_TIMEOUT } = {}) {
|
|
58
|
+
let records;
|
|
59
|
+
try {
|
|
60
|
+
records = await lookupAll(DISCOVERY_NAME, timeout);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log.debug(`NAT64 prefix discovery: ${DISCOVERY_NAME} did not resolve (${err.code || err.message})`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const synthesized = records.filter((r) => r.family === 6).map((r) => r.address);
|
|
67
|
+
if (synthesized.length === 0) {
|
|
68
|
+
log.debug('NAT64 prefix discovery: no AAAA records, so this resolver does not provide DNS64');
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const address of synthesized) {
|
|
73
|
+
const found = prefixFromSynthesized(address);
|
|
74
|
+
if (found) {
|
|
75
|
+
return { ...found, source: address };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
log.debug(
|
|
80
|
+
`NAT64 prefix discovery: ${DISCOVERY_NAME} returned ${synthesized.join(', ')}, ` +
|
|
81
|
+
`but no RFC 6052 prefix length embeds ${WELL_KNOWN_IPV4.join(' or ')}`
|
|
82
|
+
);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Discover the prefix and adopt it if it differs from the configured one.
|
|
88
|
+
*
|
|
89
|
+
* @returns {Promise<{prefix: string, length: number}|null>} The adopted prefix, if any
|
|
90
|
+
*/
|
|
91
|
+
async function discoverAndApply() {
|
|
92
|
+
const discovered = await discoverPrefix();
|
|
93
|
+
if (!discovered) return null;
|
|
94
|
+
|
|
95
|
+
const current = config.getPrefix();
|
|
96
|
+
const spec = `${discovered.prefix}/${discovered.length}`;
|
|
97
|
+
|
|
98
|
+
if (current.prefix === discovered.prefix && current.length === discovered.length) {
|
|
99
|
+
log.debug(`NAT64 prefix discovery confirmed the configured prefix ${spec}`);
|
|
100
|
+
return discovered;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.env.NAT64_PREFIX) {
|
|
104
|
+
log.warn(
|
|
105
|
+
`This network advertises NAT64 prefix ${spec}, but NAT64_PREFIX is set to ` +
|
|
106
|
+
`${current.prefix}/${current.length}. Keeping the configured value.`
|
|
107
|
+
);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log.info(`Discovered NAT64 prefix ${spec} via ${DISCOVERY_NAME} (RFC 7050)`);
|
|
112
|
+
config.setPrefix(discovered);
|
|
113
|
+
return discovered;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
DISCOVERY_NAME,
|
|
118
|
+
WELL_KNOWN_IPV4,
|
|
119
|
+
discoverPrefix,
|
|
120
|
+
discoverAndApply,
|
|
121
|
+
prefixFromSynthesized,
|
|
122
|
+
};
|