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>
This commit is contained in:
Paolo Insogna
2022-12-03 18:55:57 +01:00
committed by GitHub
parent 4712d6083d
commit f6052c68c1
9 changed files with 932 additions and 7 deletions

View File

@@ -856,6 +856,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
- v16.15.0
@@ -902,6 +905,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:
@@ -1630,6 +1647,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

View File

@@ -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) {

View File

@@ -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;
@@ -891,6 +898,7 @@ function determineSpecificType(value) {
module.exports = {
AbortError,
aggregateTwoErrors,
aggregateErrors,
captureLargerStackTrace,
codes,
connResetException,

View File

@@ -67,6 +67,7 @@ function makeSyncWrite(fd) {
}
module.exports = {
kWrapConnectedHandle: Symbol('wrapConnectedHandle'),
isIP,
isIPv4,
isIPv6,

View File

@@ -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,
@@ -119,8 +125,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 = '::';
@@ -1041,6 +1048,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
@@ -1110,9 +1184,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);
@@ -1131,6 +1205,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) {
@@ -1165,6 +1253,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);
@@ -1199,6 +1307,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);
@@ -1293,6 +1481,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;

View File

@@ -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();
}));
}));
}));
}

View File

@@ -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();
}));
}));
}));
}

View File

@@ -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');
}));
}));
}));
}

View File

@@ -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();
}));
}));
}));
}