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/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
- * Detects whether the system is on an IPv6-only network and whether
5
- * the bridge is needed to reach IPv4-only servers.
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 { IPV6_GOOGLE, IPV4_GOOGLE } = require('./config');
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
- * Test if the network has IPv6 connectivity.
24
+ * Probe a URL over a specific IP family.
16
25
  *
17
- * Connects to an IPv6-capable server to verify that IPv6 is available.
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
- * @returns {Promise<boolean>} true if IPv6 is available
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
- async function hasIPv6() {
33
+ function probe(url, family) {
22
34
  return new Promise((resolve) => {
23
- const req = http.get(IPV6_GOOGLE, { family: 6 }, (res) => {
24
- // Consume response body to free resources
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
- resolve(res.statusCode === 200);
44
+ finish(res.statusCode >= 200 && res.statusCode < 400);
27
45
  });
28
- req.on('error', () => resolve(false));
29
- req.setTimeout(5000, () => {
46
+
47
+ req.on('error', () => finish(false));
48
+ req.setTimeout(DNS_TIMEOUT, () => {
30
49
  req.destroy();
31
- resolve(false);
50
+ finish(false);
32
51
  });
33
52
  });
34
53
  }
35
54
 
36
55
  /**
37
- * Determine if the bridge is needed.
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
- * The bridge is needed when:
40
- * 1. IPv6 is available, AND
41
- * 2. IPv4 servers are NOT reachable via the ISP's NAT64 gateway
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 bridge is needed
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
- const hasV6 = await hasIPv6();
47
- if (!hasV6) {
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
- try {
54
- const ipv6 = await resolveIPv6(IPV4_GOOGLE);
55
- if (!ipv6 || ipv6.length === 0) {
56
- return true;
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
- // Try connecting to the synthesized IPv6 address.
60
- // If this works, the ISP has a working NAT64 gateway.
61
- return new Promise((resolve) => {
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 };
@@ -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
+ };