diff options
author | Paolo Insogna <paolo@cowtech.it> | 2022-12-03 18:55:57 +0100 |
---|---|---|
committer | Danielle Adams <adamzdanielle@gmail.com> | 2023-01-04 20:31:51 -0500 |
commit | 84cd1322790a74e65dc4fcc0c602b66c71285182 (patch) | |
tree | e6eb3defd0adeac10dc59482b8e8a7cdcc4dbe8f | |
parent | 7a56409aae46037698011c30dbcb257e13b2c723 (diff) | |
download | node-new-84cd1322790a74e65dc4fcc0c602b66c71285182.tar.gz |
net: add autoSelectFamily and autoSelectFamilyAttemptTimeout options
PR-URL: https://github.com/nodejs/node/pull/44731
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
-rw-r--r-- | doc/api/net.md | 18 | ||||
-rw-r--r-- | lib/_tls_wrap.js | 18 | ||||
-rw-r--r-- | lib/internal/errors.js | 8 | ||||
-rw-r--r-- | lib/internal/net.js | 1 | ||||
-rw-r--r-- | lib/net.js | 255 | ||||
-rw-r--r-- | test/parallel/test-http-happy-eyeballs.js | 148 | ||||
-rw-r--r-- | test/parallel/test-https-happy-eyeballs.js | 164 | ||||
-rw-r--r-- | test/parallel/test-net-happy-eyeballs-ipv4first.js | 112 | ||||
-rw-r--r-- | test/parallel/test-net-happy-eyeballs.js | 215 |
9 files changed, 932 insertions, 7 deletions
diff --git a/doc/api/net.md b/doc/api/net.md index 501c5ac81b..ba39e6bd34 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -854,6 +854,9 @@ behavior. <!-- YAML added: v0.1.90 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/44731 + description: Added the `autoSelectFamily` option. - version: v17.7.0 pr-url: https://github.com/nodejs/node/pull/41310 description: The `noDelay`, `keepAlive`, and `keepAliveInitialDelay` @@ -898,6 +901,20 @@ For TCP connections, available `options` are: **Default:** `false`. * `keepAliveInitialDelay` {number} If set to a positive number, it sets the initial delay before the first keepalive probe is sent on an idle socket.**Default:** `0`. +* `autoSelectFamily` {boolean}: If set to `true`, it enables a family autodetection algorithm + that loosely implements section 5 of [RFC 8305][]. + The `all` option passed to lookup is set to `true` and the sockets attempts to connect to all + obtained IPv6 and IPv4 addresses, in sequence, until a connection is established. + The first returned AAAA address is tried first, then the first returned A address and so on. + Each connection attempt is given the amount of time specified by the `autoSelectFamilyAttemptTimeout` + option before timing out and trying the next address. + Ignored if the `family` option is not `0` or if `localAddress` is set. + Connection errors are not emitted if at least one connection succeeds. + **Default:** `false`. +* `autoSelectFamilyAttemptTimeout` {number}: The amount of time in milliseconds to wait + for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. + If set to a positive integer less than `10`, then the value `10` will be used instead. + **Default:** `250`. For [IPC][] connections, available `options` are: @@ -1622,6 +1639,7 @@ net.isIPv6('fhqwhgads'); // returns false [IPC]: #ipc-support [Identifying paths for IPC connections]: #identifying-paths-for-ipc-connections +[RFC 8305]: https://www.rfc-editor.org/rfc/rfc8305.txt [Readable Stream]: stream.md#class-streamreadable [`'close'`]: #event-close [`'connect'`]: #event-connect diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js index 09e5994040..65db04e769 100644 --- a/lib/_tls_wrap.js +++ b/lib/_tls_wrap.js @@ -54,6 +54,7 @@ const EE = require('events'); const net = require('net'); const tls = require('tls'); const common = require('_tls_common'); +const { kWrapConnectedHandle } = require('internal/net'); const JSStreamSocket = require('internal/js_stream_socket'); const { Buffer } = require('buffer'); let debug = require('internal/util/debuglog').debuglog('tls', (fn) => { @@ -598,11 +599,10 @@ TLSSocket.prototype.disableRenegotiation = function disableRenegotiation() { this[kDisableRenegotiation] = true; }; -TLSSocket.prototype._wrapHandle = function(wrap) { - let handle; - - if (wrap) +TLSSocket.prototype._wrapHandle = function(wrap, handle) { + if (!handle && wrap) { handle = wrap._handle; + } const options = this._tlsOptions; if (!handle) { @@ -633,6 +633,16 @@ TLSSocket.prototype._wrapHandle = function(wrap) { return res; }; +TLSSocket.prototype[kWrapConnectedHandle] = function(handle) { + this._handle = this._wrapHandle(null, handle); + this.ssl = this._handle; + this._init(); + + if (this._tlsOptions.enableTrace) { + this._handle.enableTrace(); + } +}; + // This eliminates a cyclic reference to TLSWrap // Ref: https://github.com/nodejs/node/commit/f7620fb96d339f704932f9bb9a0dceb9952df2d4 function defineHandleReading(socket, handle) { diff --git a/lib/internal/errors.js b/lib/internal/errors.js index f68855c148..38c9d391d3 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -168,6 +168,13 @@ const aggregateTwoErrors = hideStackFrames((innerError, outerError) => { return innerError || outerError; }); +const aggregateErrors = hideStackFrames((errors, message, code) => { + // eslint-disable-next-line no-restricted-syntax + const err = new AggregateError(new SafeArrayIterator(errors), message); + err.code = errors[0]?.code; + return err; +}); + // Lazily loaded let util; let assert; @@ -893,6 +900,7 @@ function determineSpecificType(value) { module.exports = { AbortError, aggregateTwoErrors, + aggregateErrors, captureLargerStackTrace, codes, connResetException, diff --git a/lib/internal/net.js b/lib/internal/net.js index 625377acd5..35e2a03706 100644 --- a/lib/internal/net.js +++ b/lib/internal/net.js @@ -67,6 +67,7 @@ function makeSyncWrite(fd) { } module.exports = { + kWrapConnectedHandle: Symbol('wrapConnectedHandle'), isIP, isIPv4, isIPv6, diff --git a/lib/net.js b/lib/net.js index 06bb3879fa..103b6c9264 100644 --- a/lib/net.js +++ b/lib/net.js @@ -24,7 +24,10 @@ const { ArrayIsArray, ArrayPrototypeIndexOf, + ArrayPrototypePush, Boolean, + FunctionPrototypeBind, + MathMax, Number, NumberIsNaN, NumberParseInt, @@ -40,6 +43,7 @@ let debug = require('internal/util/debuglog').debuglog('net', (fn) => { debug = fn; }); const { + kWrapConnectedHandle, isIP, isIPv4, isIPv6, @@ -96,6 +100,7 @@ const { ERR_SOCKET_CLOSED, ERR_MISSING_ARGS, }, + aggregateErrors, errnoException, exceptionWithHostPort, genericNodeError, @@ -105,6 +110,7 @@ const { isUint8Array } = require('internal/util/types'); const { queueMicrotask } = require('internal/process/task_queues'); const { validateAbortSignal, + validateBoolean, validateFunction, validateInt32, validateNumber, @@ -123,8 +129,9 @@ let dns; let BlockList; let SocketAddress; -const { clearTimeout } = require('timers'); +const { clearTimeout, setTimeout } = require('timers'); const { kTimeout } = require('internal/timers'); +const kTimeoutTriggered = Symbol('kTimeoutTriggered'); const DEFAULT_IPV4_ADDR = '0.0.0.0'; const DEFAULT_IPV6_ADDR = '::'; @@ -1057,6 +1064,73 @@ function internalConnect( } +function internalConnectMultiple(context) { + clearTimeout(context[kTimeout]); + const self = context.socket; + assert(self.connecting); + + // All connections have been tried without success, destroy with error + if (context.current === context.addresses.length) { + self.destroy(aggregateErrors(context.errors)); + return; + } + + const { localPort, port, flags } = context; + const { address, family: addressType } = context.addresses[context.current++]; + const handle = new TCP(TCPConstants.SOCKET); + let localAddress; + let err; + + if (localPort) { + if (addressType === 4) { + localAddress = DEFAULT_IPV4_ADDR; + err = handle.bind(localAddress, localPort); + } else { // addressType === 6 + localAddress = DEFAULT_IPV6_ADDR; + err = handle.bind6(localAddress, localPort, flags); + } + + debug('connect/multiple: binding to localAddress: %s and localPort: %d (addressType: %d)', + localAddress, localPort, addressType); + + err = checkBindError(err, localPort, handle); + if (err) { + ArrayPrototypePush(context.errors, exceptionWithHostPort(err, 'bind', localAddress, localPort)); + internalConnectMultiple(context); + return; + } + } + + const req = new TCPConnectWrap(); + req.oncomplete = FunctionPrototypeBind(afterConnectMultiple, undefined, context); + req.address = address; + req.port = port; + req.localAddress = localAddress; + req.localPort = localPort; + + if (addressType === 4) { + err = handle.connect(req, address, port); + } else { + err = handle.connect6(req, address, port); + } + + if (err) { + const sockname = self._getsockname(); + let details; + + if (sockname) { + details = sockname.address + ':' + sockname.port; + } + + ArrayPrototypePush(context.errors, exceptionWithHostPort(err, 'connect', address, port, details)); + internalConnectMultiple(context); + return; + } + + // If the attempt has not returned an error, start the connection timer + context[kTimeout] = setTimeout(internalConnectMultipleTimeout, context.timeout, context, req); +} + Socket.prototype.connect = function(...args) { let normalized; // If passed an array, it's treated as an array of arguments that have @@ -1126,9 +1200,9 @@ function socketToDnsFamily(family) { } function lookupAndConnect(self, options) { - const { localAddress, localPort } = options; + const { localAddress, localPort, autoSelectFamily } = options; const host = options.host || 'localhost'; - let { port } = options; + let { port, autoSelectFamilyAttemptTimeout } = options; if (localAddress && !isIP(localAddress)) { throw new ERR_INVALID_IP_ADDRESS(localAddress); @@ -1147,6 +1221,20 @@ function lookupAndConnect(self, options) { } port |= 0; + if (autoSelectFamily !== undefined) { + validateBoolean(autoSelectFamily); + } + + if (autoSelectFamilyAttemptTimeout !== undefined) { + validateInt32(autoSelectFamilyAttemptTimeout); + + if (autoSelectFamilyAttemptTimeout < 10) { + autoSelectFamilyAttemptTimeout = 10; + } + } else { + autoSelectFamilyAttemptTimeout = 250; + } + // If host is an IP, skip performing a lookup const addressType = isIP(host); if (addressType) { @@ -1181,6 +1269,26 @@ function lookupAndConnect(self, options) { debug('connect: dns options', dnsopts); self._host = host; const lookup = options.lookup || dns.lookup; + + if (dnsopts.family !== 4 && dnsopts.family !== 6 && !localAddress && autoSelectFamily) { + debug('connect: autodetecting'); + + dnsopts.all = true; + lookupAndConnectMultiple( + self, + async_id_symbol, + lookup, + host, + options, + dnsopts, + port, + localPort, + autoSelectFamilyAttemptTimeout + ); + + return; + } + defaultTriggerAsyncIdScope(self[async_id_symbol], function() { lookup(host, dnsopts, function emitLookup(err, ip, addressType) { self.emit('lookup', err, ip, addressType, host); @@ -1215,6 +1323,86 @@ function lookupAndConnect(self, options) { }); } +function lookupAndConnectMultiple(self, async_id_symbol, lookup, host, options, dnsopts, port, localPort, timeout) { + defaultTriggerAsyncIdScope(self[async_id_symbol], function emitLookup() { + lookup(host, dnsopts, function emitLookup(err, addresses) { + // It's possible we were destroyed while looking this up. + // XXX it would be great if we could cancel the promise returned by + // the look up. + if (!self.connecting) { + return; + } else if (err) { + // net.createConnection() creates a net.Socket object and immediately + // calls net.Socket.connect() on it (that's us). There are no event + // listeners registered yet so defer the error event to the next tick. + process.nextTick(connectErrorNT, self, err); + return; + } + + // Filter addresses by only keeping the one which are either IPv4 or IPV6. + // The first valid address determines which group has preference on the + // alternate family sorting which happens later. + const validIps = [[], []]; + let destinations; + for (let i = 0, l = addresses.length; i < l; i++) { + const address = addresses[i]; + const { address: ip, family: addressType } = address; + self.emit('lookup', err, ip, addressType, host); + + if (isIP(ip) && (addressType === 4 || addressType === 6)) { + if (!destinations) { + destinations = addressType === 6 ? { 6: 0, 4: 1 } : { 4: 0, 6: 1 }; + } + + ArrayPrototypePush(validIps[destinations[addressType]], address); + } + } + + // When no AAAA or A records are available, fail on the first one + if (!validIps[0].length && !validIps[1].length) { + const { address: firstIp, family: firstAddressType } = addresses[0]; + + if (!isIP(firstIp)) { + err = new ERR_INVALID_IP_ADDRESS(firstIp); + process.nextTick(connectErrorNT, self, err); + } else if (firstAddressType !== 4 && firstAddressType !== 6) { + err = new ERR_INVALID_ADDRESS_FAMILY(firstAddressType, + options.host, + options.port); + process.nextTick(connectErrorNT, self, err); + } + + return; + } + + // Sort addresses alternating families + const toAttempt = []; + for (let i = 0, l = MathMax(validIps[0].length, validIps[1].length); i < l; i++) { + if (i in validIps[0]) { + ArrayPrototypePush(toAttempt, validIps[0][i]); + } + if (i in validIps[1]) { + ArrayPrototypePush(toAttempt, validIps[1][i]); + } + } + + const context = { + socket: self, + addresses, + current: 0, + port, + localPort, + timeout, + [kTimeout]: null, + [kTimeoutTriggered]: false, + errors: [], + }; + + self._unrefTimer(); + defaultTriggerAsyncIdScope(self[async_id_symbol], internalConnectMultiple, context); + }); + }); +} function connectErrorNT(self, err) { self.destroy(err); @@ -1309,6 +1497,67 @@ function afterConnect(status, handle, req, readable, writable) { } } +function afterConnectMultiple(context, status, handle, req, readable, writable) { + const self = context.socket; + + // Make sure another connection is not spawned + clearTimeout(context[kTimeout]); + + // Some error occurred, add to the list of exceptions + if (status !== 0) { + let details; + if (req.localAddress && req.localPort) { + details = req.localAddress + ':' + req.localPort; + } + const ex = exceptionWithHostPort(status, + 'connect', + req.address, + req.port, + details); + if (details) { + ex.localAddress = req.localAddress; + ex.localPort = req.localPort; + } + + ArrayPrototypePush(context.errors, ex); + + // Try the next address + internalConnectMultiple(context); + return; + } + + // One of the connection has completed and correctly dispatched but after timeout, ignore this one + if (context[kTimeoutTriggered]) { + debug('connect/multiple: ignoring successful but timedout connection to %s:%s', req.address, req.port); + handle.close(); + return; + } + + // Perform initialization sequence on the handle, then move on with the regular callback + self._handle = handle; + initSocketHandle(self); + + if (self[kWrapConnectedHandle]) { + self[kWrapConnectedHandle](handle); + initSocketHandle(self); // This is called again to initialize the TLSWrap + } + + if (hasObserver('net')) { + startPerf( + self, + kPerfHooksNetConnectContext, + { type: 'net', name: 'connect', detail: { host: req.address, port: req.port } } + ); + } + + afterConnect(status, handle, req, readable, writable); +} + +function internalConnectMultipleTimeout(context, req) { + context[kTimeoutTriggered] = true; + internalConnectMultiple(context); +} + function addAbortSignalOption(self, options) { if (options?.signal === undefined) { return; diff --git a/test/parallel/test-http-happy-eyeballs.js b/test/parallel/test-http-happy-eyeballs.js new file mode 100644 index 0000000000..da81ce9a01 --- /dev/null +++ b/test/parallel/test-http-happy-eyeballs.js @@ -0,0 +1,148 @@ +'use strict'; + +const common = require('../common'); +const { parseDNSPacket, writeDNSPacket } = require('../common/dns'); + +const assert = require('assert'); +const dgram = require('dgram'); +const { Resolver } = require('dns'); +const { request, createServer } = require('http'); + +// Test that happy eyeballs algorithm is properly implemented when using HTTP. + +let autoSelectFamilyAttemptTimeout = common.platformTimeout(250); +if (common.isWindows) { + // Some of the windows machines in the CI need more time to establish connection + autoSelectFamilyAttemptTimeout = common.platformTimeout(1500); +} + +function _lookup(resolver, hostname, options, cb) { + resolver.resolve(hostname, 'ANY', (err, replies) => { + assert.notStrictEqual(options.family, 4); + + if (err) { + return cb(err); + } + + const hosts = replies + .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 })) + .sort((a, b) => b.family - a.family); + + if (options.all === true) { + return cb(null, hosts); + } + + return cb(null, hosts[0].address, hosts[0].family); + }); +} + +function createDnsServer(ipv6Addr, ipv4Addr, cb) { + // Create a DNS server which replies with a AAAA and a A record for the same host + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((msg, { address, port }) => { + const parsed = parseDNSPacket(msg); + const domain = parsed.questions[0].domain; + assert.strictEqual(domain, 'example.org'); + + socket.send(writeDNSPacket({ + id: parsed.id, + questions: parsed.questions, + answers: [ + { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' }, + { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' }, + ] + }), port, address); + })); + + socket.bind(0, () => { + const resolver = new Resolver(); + resolver.setServers([`127.0.0.1:${socket.address().port}`]); + + cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) }); + }); +} + +// Test that IPV4 is reached if IPV6 is not reachable +{ + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer(common.mustCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv4'); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + request( + `http://example.org:${ipv4Server.address().port}/`, + { + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout + }, + (res) => { + assert.strictEqual(res.statusCode, 200); + res.setEncoding('utf-8'); + + let response = ''; + + res.on('data', (chunk) => { + response += chunk; + }); + + res.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + dnsServer.close(); + })); + } + ).end(); + })); + })); +} + +// Test that IPV4 is NOT reached if IPV6 is reachable +if (common.hasIPv6) { + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer(common.mustNotCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv4'); + })); + + const ipv6Server = createServer(common.mustCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv6'); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + request( + `http://example.org:${ipv4Server.address().port}/`, + { + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }, + (res) => { + assert.strictEqual(res.statusCode, 200); + res.setEncoding('utf-8'); + + let response = ''; + + res.on('data', (chunk) => { + response += chunk; + }); + + res.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv6'); + ipv4Server.close(); + ipv6Server.close(); + dnsServer.close(); + })); + } + ).end(); + })); + })); + })); +} diff --git a/test/parallel/test-https-happy-eyeballs.js b/test/parallel/test-https-happy-eyeballs.js new file mode 100644 index 0000000000..789a95c551 --- /dev/null +++ b/test/parallel/test-https-happy-eyeballs.js @@ -0,0 +1,164 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const { parseDNSPacket, writeDNSPacket } = require('../common/dns'); +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const dgram = require('dgram'); +const { Resolver } = require('dns'); +const { request, createServer } = require('https'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}; + +// Test that happy eyeballs algorithm is properly implemented when using HTTP. + +let autoSelectFamilyAttemptTimeout = common.platformTimeout(250); +if (common.isWindows) { + // Some of the windows machines in the CI need more time to establish connection + autoSelectFamilyAttemptTimeout = common.platformTimeout(1500); +} + +function _lookup(resolver, hostname, options, cb) { + resolver.resolve(hostname, 'ANY', (err, replies) => { + assert.notStrictEqual(options.family, 4); + + if (err) { + return cb(err); + } + + const hosts = replies + .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 })) + .sort((a, b) => b.family - a.family); + + if (options.all === true) { + return cb(null, hosts); + } + + return cb(null, hosts[0].address, hosts[0].family); + }); +} + +function createDnsServer(ipv6Addr, ipv4Addr, cb) { + // Create a DNS server which replies with a AAAA and a A record for the same host + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((msg, { address, port }) => { + const parsed = parseDNSPacket(msg); + const domain = parsed.questions[0].domain; + assert.strictEqual(domain, 'example.org'); + + socket.send(writeDNSPacket({ + id: parsed.id, + questions: parsed.questions, + answers: [ + { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' }, + { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' }, + ] + }), port, address); + })); + + socket.bind(0, () => { + const resolver = new Resolver(); + resolver.setServers([`127.0.0.1:${socket.address().port}`]); + + cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) }); + }); +} + +// Test that IPV4 is reached if IPV6 is not reachable +{ + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer(options, common.mustCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv4'); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + request( + `https://example.org:${ipv4Server.address().port}/`, + { + lookup, + rejectUnauthorized: false, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout + }, + (res) => { + assert.strictEqual(res.statusCode, 200); + res.setEncoding('utf-8'); + + let response = ''; + + res.on('data', (chunk) => { + response += chunk; + }); + + res.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + dnsServer.close(); + })); + } + ).end(); + })); + })); +} + +// Test that IPV4 is NOT reached if IPV6 is reachable +if (common.hasIPv6) { + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer(options, common.mustNotCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv4'); + })); + + const ipv6Server = createServer(options, common.mustCall((_, res) => { + res.writeHead(200, { Connection: 'close' }); + res.end('response-ipv6'); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + request( + `https://example.org:${ipv4Server.address().port}/`, + { + lookup, + rejectUnauthorized: false, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }, + (res) => { + assert.strictEqual(res.statusCode, 200); + res.setEncoding('utf-8'); + + let response = ''; + + res.on('data', (chunk) => { + response += chunk; + }); + + res.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv6'); + ipv4Server.close(); + ipv6Server.close(); + dnsServer.close(); + })); + } + ).end(); + })); + })); + })); +} diff --git a/test/parallel/test-net-happy-eyeballs-ipv4first.js b/test/parallel/test-net-happy-eyeballs-ipv4first.js new file mode 100644 index 0000000000..00bff09ac6 --- /dev/null +++ b/test/parallel/test-net-happy-eyeballs-ipv4first.js @@ -0,0 +1,112 @@ +'use strict'; + +const common = require('../common'); +const { parseDNSPacket, writeDNSPacket } = require('../common/dns'); + +const assert = require('assert'); +const dgram = require('dgram'); +const { Resolver } = require('dns'); +const { createConnection, createServer } = require('net'); + +// Test that happy eyeballs algorithm is properly implemented when a A record is returned first. + +let autoSelectFamilyAttemptTimeout = common.platformTimeout(250); +if (common.isWindows) { + // Some of the windows machines in the CI need more time to establish connection + autoSelectFamilyAttemptTimeout = common.platformTimeout(1500); +} + +function _lookup(resolver, hostname, options, cb) { + resolver.resolve(hostname, 'ANY', (err, replies) => { + assert.notStrictEqual(options.family, 4); + + if (err) { + return cb(err); + } + + const hosts = replies + .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 })); + + if (options.all === true) { + return cb(null, hosts); + } + + return cb(null, hosts[0].address, hosts[0].family); + }); +} + +function createDnsServer(ipv6Addr, ipv4Addr, cb) { + // Create a DNS server which replies with a AAAA and a A record for the same host + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((msg, { address, port }) => { + const parsed = parseDNSPacket(msg); + const domain = parsed.questions[0].domain; + assert.strictEqual(domain, 'example.org'); + + socket.send(writeDNSPacket({ + id: parsed.id, + questions: parsed.questions, + answers: [ + { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' }, + { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' }, + ] + }), port, address); + })); + + socket.bind(0, () => { + const resolver = new Resolver(); + resolver.setServers([`127.0.0.1:${socket.address().port}`]); + + cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) }); + }); +} + +// Test that IPV6 is NOT reached if IPV4 is sorted first +if (common.hasIPv6) { + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + const ipv6Server = createServer((socket) => { + socket.on('data', common.mustNotCall(() => { + socket.write('response-ipv6'); + socket.end(); + })); + }); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + const connection = createConnection({ + host: 'example.org', + port, + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + ipv6Server.close(); + dnsServer.close(); + })); + + connection.write('request'); + })); + })); + })); +} diff --git a/test/parallel/test-net-happy-eyeballs.js b/test/parallel/test-net-happy-eyeballs.js new file mode 100644 index 0000000000..a7b19a074e --- /dev/null +++ b/test/parallel/test-net-happy-eyeballs.js @@ -0,0 +1,215 @@ +'use strict'; + +const common = require('../common'); +const { parseDNSPacket, writeDNSPacket } = require('../common/dns'); + +const assert = require('assert'); +const dgram = require('dgram'); +const { Resolver } = require('dns'); +const { createConnection, createServer } = require('net'); + +// Test that happy eyeballs algorithm is properly implemented. + +let autoSelectFamilyAttemptTimeout = common.platformTimeout(250); +if (common.isWindows) { + // Some of the windows machines in the CI need more time to establish connection + autoSelectFamilyAttemptTimeout = common.platformTimeout(1500); +} + +function _lookup(resolver, hostname, options, cb) { + resolver.resolve(hostname, 'ANY', (err, replies) => { + assert.notStrictEqual(options.family, 4); + + if (err) { + return cb(err); + } + + const hosts = replies + .map((r) => ({ address: r.address, family: r.type === 'AAAA' ? 6 : 4 })) + .sort((a, b) => b.family - a.family); + + if (options.all === true) { + return cb(null, hosts); + } + + return cb(null, hosts[0].address, hosts[0].family); + }); +} + +function createDnsServer(ipv6Addr, ipv4Addr, cb) { + // Create a DNS server which replies with a AAAA and a A record for the same host + const socket = dgram.createSocket('udp4'); + + socket.on('message', common.mustCall((msg, { address, port }) => { + const parsed = parseDNSPacket(msg); + const domain = parsed.questions[0].domain; + assert.strictEqual(domain, 'example.org'); + + socket.send(writeDNSPacket({ + id: parsed.id, + questions: parsed.questions, + answers: [ + { type: 'AAAA', address: ipv6Addr, ttl: 123, domain: 'example.org' }, + { type: 'A', address: ipv4Addr, ttl: 123, domain: 'example.org' }, + ] + }), port, address); + })); + + socket.bind(0, () => { + const resolver = new Resolver(); + resolver.setServers([`127.0.0.1:${socket.address().port}`]); + + cb({ dnsServer: socket, lookup: _lookup.bind(null, resolver) }); + }); +} + +// Test that IPV4 is reached if IPV6 is not reachable +{ + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const connection = createConnection({ + host: 'example.org', + port: ipv4Server.address().port, + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + dnsServer.close(); + })); + + connection.write('request'); + })); + })); +} + +// Test that IPV4 is NOT reached if IPV6 is reachable +if (common.hasIPv6) { + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustNotCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + const ipv6Server = createServer((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv6'); + socket.end(); + })); + }); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + const connection = createConnection({ + host: 'example.org', + port, + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv6'); + ipv4Server.close(); + ipv6Server.close(); + dnsServer.close(); + })); + + connection.write('request'); + })); + })); + })); +} + +// Test that when all errors are returned when no connections succeeded +{ + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const connection = createConnection({ + host: 'example.org', + port: 10, + lookup, + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.strictEqual(error.constructor.name, 'AggregateError'); + assert.strictEqual(error.errors.length, 2); + + const errors = error.errors.map((e) => e.message); + assert.ok(errors.includes('connect ECONNREFUSED 127.0.0.1:10')); + + if (common.hasIPv6) { + assert.ok(errors.includes('connect ECONNREFUSED ::1:10')); + } + + dnsServer.close(); + })); + })); +} + +// Test that the option can be disabled +{ + createDnsServer('::1', '127.0.0.1', common.mustCall(function({ dnsServer, lookup }) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port, + lookup, + autoSelectFamily: false, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + if (common.hasIPv6) { + assert.strictEqual(error.code, 'ECONNREFUSED'); + assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`); + } else { + assert.strictEqual(error.code, 'EADDRNOTAVAIL'); + assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`); + } + + ipv4Server.close(); + dnsServer.close(); + })); + })); + })); +} |