Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(NODE-4621): ipv6 address handling in HostAddress #3410

Merged
merged 4 commits into from Sep 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/cmap/connection.ts
Expand Up @@ -655,8 +655,9 @@ function streamIdentifier(stream: Stream, options: ConnectionOptions): string {
return options.hostAddress.toString();
}

if (typeof stream.address === 'function') {
return `${stream.remoteAddress}:${stream.remotePort}`;
const { remoteAddress, remotePort } = stream;
if (typeof remoteAddress === 'string' && typeof remotePort === 'number') {
return HostAddress.fromHostPort(remoteAddress, remotePort).toString();
}

return uuidV4().toString('hex');
Expand Down
4 changes: 2 additions & 2 deletions src/sdam/server_description.ts
Expand Up @@ -88,8 +88,8 @@ export class ServerDescription {

this.address =
typeof address === 'string'
? HostAddress.fromString(address).toString(false) // Use HostAddress to normalize
: address.toString(false);
? HostAddress.fromString(address).toString() // Use HostAddress to normalize
: address.toString();
this.type = parseServerType(hello, options);
this.hosts = hello?.hosts?.map((host: string) => host.toLowerCase()) ?? [];
this.passives = hello?.passives?.map((host: string) => host.toLowerCase()) ?? [];
Expand Down
68 changes: 36 additions & 32 deletions src/utils.ts
Expand Up @@ -1142,44 +1142,51 @@ export class BufferPool {

/** @public */
export class HostAddress {
host;
port;
// Driver only works with unix socket path to connect
// SDAM operates only on tcp addresses
socketPath;
isIPv6;
host: string | undefined = undefined;
port: number | undefined = undefined;
socketPath: string | undefined = undefined;
isIPv6 = false;

constructor(hostString: string) {
const escapedHost = hostString.split(' ').join('%20'); // escape spaces, for socket path hosts
const { hostname, port } = new URL(`mongodb://${escapedHost}`);

if (escapedHost.endsWith('.sock')) {
// heuristically determine if we're working with a domain socket
this.socketPath = decodeURIComponent(escapedHost);
} else if (typeof hostname === 'string') {
this.isIPv6 = false;
return;
}

let normalized = decodeURIComponent(hostname).toLowerCase();
if (normalized.startsWith('[') && normalized.endsWith(']')) {
this.isIPv6 = true;
normalized = normalized.substring(1, hostname.length - 1);
}
const urlString = `iLoveJS://${escapedHost}`;
let url;
try {
url = new URL(urlString);
} catch (urlError) {
const runtimeError = new MongoRuntimeError(`Unable to parse ${escapedHost} with URL`);
runtimeError.cause = urlError;
throw runtimeError;
}

this.host = normalized.toLowerCase();
const hostname = url.hostname;
const port = url.port;

if (typeof port === 'number') {
this.port = port;
} else if (typeof port === 'string' && port !== '') {
this.port = Number.parseInt(port, 10);
} else {
this.port = 27017;
}
let normalized = decodeURIComponent(hostname).toLowerCase();
if (normalized.startsWith('[') && normalized.endsWith(']')) {
this.isIPv6 = true;
normalized = normalized.substring(1, hostname.length - 1);
}

if (this.port === 0) {
throw new MongoParseError('Invalid port (zero) with hostname');
}
this.host = normalized.toLowerCase();

if (typeof port === 'number') {
this.port = port;
} else if (typeof port === 'string' && port !== '') {
this.port = Number.parseInt(port, 10);
} else {
throw new MongoInvalidArgumentError('Either socketPath or host must be defined.');
this.port = 27017;
}

if (this.port === 0) {
throw new MongoParseError('Invalid port (zero) with hostname');
}
Object.freeze(this);
}
Expand All @@ -1189,15 +1196,12 @@ export class HostAddress {
}

inspect(): string {
return `new HostAddress('${this.toString(true)}')`;
return `new HostAddress('${this.toString()}')`;
}

/**
* @param ipv6Brackets - optionally request ipv6 bracket notation required for connection strings
*/
toString(ipv6Brackets = false): string {
toString(): string {
if (typeof this.host === 'string') {
if (this.isIPv6 && ipv6Brackets) {
if (this.isIPv6) {
return `[${this.host}]:${this.port}`;
}
return `${this.host}:${this.port}`;
Expand Down
40 changes: 11 additions & 29 deletions test/integration/crud/misc_cursors.test.js
Expand Up @@ -264,39 +264,21 @@ describe('Cursor', function () {
}
});

it('Should correctly execute cursor count with secondary readPreference', {
// Add a tag that our runner can trigger on
// in this case we are setting that node needs to be higher than 0.10.X to run
metadata: {
requires: { topology: 'replicaset' }
},

test: function (done) {
const configuration = this.configuration;
const client = configuration.newClient(configuration.writeConcernMax(), {
maxPoolSize: 1,
monitorCommands: true
});

it('should correctly execute cursor count with secondary readPreference', {
metadata: { requires: { topology: 'replicaset' } },
async test() {
const bag = [];
client.on('commandStarted', filterForCommands(['count'], bag));

client.connect((err, client) => {
expect(err).to.not.exist;
this.defer(() => client.close());

const db = client.db(configuration.db);
const cursor = db.collection('countTEST').find({ qty: { $gt: 4 } });
cursor.count({ readPreference: ReadPreference.SECONDARY }, err => {
expect(err).to.not.exist;

const selectedServerAddress = bag[0].address.replace('127.0.0.1', 'localhost');
const selectedServer = client.topology.description.servers.get(selectedServerAddress);
expect(selectedServer).property('type').to.equal(ServerType.RSSecondary);
const cursor = client
.db()
.collection('countTEST')
.find({ qty: { $gt: 4 } });
await cursor.count({ readPreference: ReadPreference.SECONDARY });

done();
});
});
const selectedServerAddress = bag[0].address.replace('127.0.0.1', 'localhost');
const selectedServer = client.topology.description.servers.get(selectedServerAddress);
expect(selectedServer).property('type').to.equal(ServerType.RSSecondary);
}
});

Expand Down
94 changes: 94 additions & 0 deletions test/integration/node-specific/ipv6.test.ts
@@ -0,0 +1,94 @@
import { expect } from 'chai';
import * as net from 'net';
import * as process from 'process';
import * as sinon from 'sinon';

import { ConnectionCreatedEvent, MongoClient, ReadPreference, TopologyType } from '../../../src';
import { byStrings, sorted } from '../../tools/utils';

describe('IPv6 Addresses', () => {
let client: MongoClient;
let ipv6Hosts: string[];

beforeEach(async function () {
if (
process.platform === 'linux' ||
this.configuration.topologyType !== TopologyType.ReplicaSetWithPrimary
) {
if (this.currentTest) {
// Ubuntu 18 (linux) does not support localhost AAAA lookups (IPv6)
// Windows (VS2019) has the AAAA lookup
// We do not run a replica set on macos
dariakp marked this conversation as resolved.
Show resolved Hide resolved
this.currentTest.skipReason =
'We are only running this on windows currently because it has the IPv6 translation for localhost';
}
return this.skip();
}

ipv6Hosts = this.configuration.options.hostAddresses.map(({ port }) => `[::1]:${port}`);
client = this.configuration.newClient(`mongodb://${ipv6Hosts.join(',')}/test`, {
[Symbol.for('@@mdb.skipPingOnConnect')]: true,
maxPoolSize: 1
});
});

afterEach(async function () {
sinon.restore();
await client?.close();
});

it('should have IPv6 loopback addresses set on the client', function () {
const ipv6LocalhostAddresses = this.configuration.options.hostAddresses.map(({ port }) => ({
host: '::1',
port,
isIPv6: true,
socketPath: undefined
}));
expect(client.options.hosts).to.deep.equal(ipv6LocalhostAddresses);
});

it('should successfully connect using IPv6 loopback addresses', async function () {
const localhostHosts = this.configuration.options.hostAddresses.map(
({ port }) => `localhost:${port}` // ::1 will be swapped out for localhost
addaleax marked this conversation as resolved.
Show resolved Hide resolved
);
await client.db().command({ ping: 1 });
// After running the first command we should receive the hosts back as reported by the mongod in a hello response
// mongodb will report the bound host address, in this case "localhost"
expect(client.topology).to.exist;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
dariakp marked this conversation as resolved.
Show resolved Hide resolved
expect(sorted(client.topology!.s.description.servers.keys(), byStrings)).to.deep.equal(
localhostHosts
);
});

it('should createConnection with IPv6 addresses initially then switch to mongodb bound addresses', async () => {
const createConnectionSpy = sinon.spy(net, 'createConnection');

const connectionCreatedEvents: ConnectionCreatedEvent[] = [];
client.on('connectionCreated', ev => connectionCreatedEvents.push(ev));

await client.db().command({ ping: 1 }, { readPreference: ReadPreference.primary });

const callArgs = createConnectionSpy.getCalls().map(({ args }) => args[0]);

// This is 7 because we create 3 monitoring connections with ::1, then another 3 with localhost
// and then 1 more in the connection pool for the operation, that is why we are checking for the connectionCreated event
expect(callArgs).to.be.lengthOf(7);
expect(connectionCreatedEvents).to.have.lengthOf(1);
expect(connectionCreatedEvents[0]).to.have.property('address').that.includes('localhost');

for (let index = 0; index < 3; index++) {
// The first 3 connections (monitoring) are made using the user provided addresses
expect(callArgs[index]).to.have.property('host', '::1');
}

for (let index = 3; index < 6; index++) {
// MongoDB sends back hellos that have the bound address 'localhost'
// We make new connection using that address instead
expect(callArgs[index]).to.have.property('host', 'localhost');
}

// Operation connection
expect(callArgs[6]).to.have.property('host', 'localhost');
});
});
6 changes: 3 additions & 3 deletions test/tools/cluster_setup.sh
Expand Up @@ -13,15 +13,15 @@ SHARDED_DIR=${SHARDED_DIR:-$DATA_DIR/sharded_cluster}

if [[ $1 == "replica_set" ]]; then
mkdir -p $REPLICASET_DIR # user / password
mlaunch init --dir $REPLICASET_DIR --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --arbiter --name rs --port 31000 --enableMajorityReadConcern --setParameter enableTestCommands=1
mlaunch init --dir $REPLICASET_DIR --ipv6 --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --arbiter --name rs --port 31000 --enableMajorityReadConcern --setParameter enableTestCommands=1
dariakp marked this conversation as resolved.
Show resolved Hide resolved
echo "mongodb://bob:pwd123@localhost:31000,localhost:31001,localhost:31002/?replicaSet=rs"
elif [[ $1 == "sharded_cluster" ]]; then
mkdir -p $SHARDED_DIR
mlaunch init --dir $SHARDED_DIR --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --name rs --port 51000 --enableMajorityReadConcern --setParameter enableTestCommands=1 --sharded 1 --mongos 2
mlaunch init --dir $SHARDED_DIR --ipv6 --auth --username "bob" --password "pwd123" --replicaset --nodes 3 --name rs --port 51000 --enableMajorityReadConcern --setParameter enableTestCommands=1 --sharded 1 --mongos 2
echo "mongodb://bob:pwd123@localhost:51000,localhost:51001"
elif [[ $1 == "server" ]]; then
mkdir -p $SINGLE_DIR
mlaunch init --dir $SINGLE_DIR --auth --username "bob" --password "pwd123" --single --setParameter enableTestCommands=1
mlaunch init --dir $SINGLE_DIR --ipv6 --auth --username "bob" --password "pwd123" --single --setParameter enableTestCommands=1
echo "mongodb://bob:pwd123@localhost:27017"
else
echo "unsupported topology: $1"
Expand Down
5 changes: 4 additions & 1 deletion test/tools/cmap_spec_runner.ts
Expand Up @@ -391,7 +391,10 @@ async function runCmapTest(test: CmapTest, threadContext: ThreadContext) {
if (expectedError) {
expect(actualError).to.exist;
const { type: errorType, message: errorMessage, ...errorPropsToCheck } = expectedError;
expect(actualError).to.have.property('name', `Mongo${errorType}`);
expect(
actualError,
`${actualError.name} does not match "Mongo${errorType}", ${actualError.message} ${actualError.stack}`
).to.have.property('name', `Mongo${errorType}`);
if (errorMessage) {
if (
errorMessage === 'Timed out while checking out a connection from connection pool' &&
Expand Down
2 changes: 1 addition & 1 deletion test/tools/runner/config.ts
Expand Up @@ -58,7 +58,7 @@ export class TestConfiguration {
buildInfo: Record<string, any>;
options: {
hosts?: string[];
hostAddresses?: HostAddress[];
hostAddresses: HostAddress[];
hostAddress?: HostAddress;
host?: string;
port?: number;
Expand Down
6 changes: 3 additions & 3 deletions test/tools/uri_spec_runner.ts
@@ -1,6 +1,6 @@
import { expect } from 'chai';

import { MongoAPIError, MongoParseError } from '../../src';
import { MongoAPIError, MongoParseError, MongoRuntimeError } from '../../src';
import { MongoClient } from '../../src/mongo_client';

type HostObject = {
Expand Down Expand Up @@ -69,8 +69,8 @@ export function executeUriValidationTest(
new MongoClient(test.uri);
expect.fail(`Expected "${test.uri}" to be invalid${test.valid ? ' because of warning' : ''}`);
} catch (err) {
if (err instanceof TypeError) {
expect(err).to.have.property('code').equal('ERR_INVALID_URL');
if (err instanceof MongoRuntimeError) {
expect(err).to.have.nested.property('cause.code').equal('ERR_INVALID_URL');
} else if (
// most of our validation is MongoParseError, which does not extend from MongoAPIError
!(err instanceof MongoParseError) &&
Expand Down
50 changes: 49 additions & 1 deletion test/unit/connection_string.test.ts
Expand Up @@ -10,7 +10,8 @@ import {
MongoAPIError,
MongoDriverError,
MongoInvalidArgumentError,
MongoParseError
MongoParseError,
MongoRuntimeError
} from '../../src/error';
import { MongoClient, MongoOptions } from '../../src/mongo_client';

Expand Down Expand Up @@ -573,4 +574,51 @@ describe('Connection String', function () {
expect(client.s.options).to.have.property(flag, null);
});
});

describe('IPv6 host addresses', () => {
it('should not allow multiple unbracketed portless localhost IPv6 addresses', () => {
// Note there is no "port-full" version of this test, there's no way to distinguish when a port begins without brackets
expect(() => new MongoClient('mongodb://::1,::1,::1/test')).to.throw(
/invalid connection string/i
);
});

it('should not allow multiple unbracketed portless remote IPv6 addresses', () => {
expect(
() =>
new MongoClient(
'mongodb://ABCD:f::abcd:abcd:abcd:abcd,ABCD:f::abcd:abcd:abcd:abcd,ABCD:f::abcd:abcd:abcd:abcd/test'
)
).to.throw(MongoRuntimeError);
});

it('should allow multiple bracketed portless localhost IPv6 addresses', () => {
const client = new MongoClient('mongodb://[::1],[::1],[::1]/test');
expect(client.options.hosts).to.deep.equal([
{ host: '::1', port: 27017, isIPv6: true, socketPath: undefined },
{ host: '::1', port: 27017, isIPv6: true, socketPath: undefined },
{ host: '::1', port: 27017, isIPv6: true, socketPath: undefined }
]);
});

it('should allow multiple bracketed portless remote IPv6 addresses', () => {
const client = new MongoClient(
'mongodb://[ABCD:f::abcd:abcd:abcd:abcd],[ABCD:f::abcd:abcd:abcd:abcd],[ABCD:f::abcd:abcd:abcd:abcd]/test'
);
expect(client.options.hosts).to.deep.equal([
{ host: 'abcd:f::abcd:abcd:abcd:abcd', port: 27017, isIPv6: true, socketPath: undefined },
{ host: 'abcd:f::abcd:abcd:abcd:abcd', port: 27017, isIPv6: true, socketPath: undefined },
{ host: 'abcd:f::abcd:abcd:abcd:abcd', port: 27017, isIPv6: true, socketPath: undefined }
]);
});

it('should allow multiple bracketed IPv6 addresses with specified ports', () => {
const client = new MongoClient('mongodb://[::1]:27018,[::1]:27019,[::1]:27020/test');
expect(client.options.hosts).to.deep.equal([
{ host: '::1', port: 27018, isIPv6: true, socketPath: undefined },
{ host: '::1', port: 27019, isIPv6: true, socketPath: undefined },
{ host: '::1', port: 27020, isIPv6: true, socketPath: undefined }
]);
});
});
});