Skip to content

Commit

Permalink
fix(NODE-4621): ipv6 address handling in HostAddress
Browse files Browse the repository at this point in the history
  • Loading branch information
nbbeeken committed Sep 14, 2022
1 parent 085471d commit b203159
Show file tree
Hide file tree
Showing 11 changed files with 171 additions and 73 deletions.
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: 38 additions & 30 deletions src/utils.ts
Expand Up @@ -1142,44 +1142,55 @@ export class BufferPool {

/** @public */
export class HostAddress {
host;
port;
host: string | undefined;
port: number | undefined;
// Driver only works with unix socket path to connect
// SDAM operates only on tcp addresses
socketPath;
isIPv6;
socketPath: string | 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;
delete this.port;
delete this.host;
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 +1200,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
48 changes: 48 additions & 0 deletions test/integration/node-specific/ipv6.test.ts
@@ -0,0 +1,48 @@
import { expect } from 'chai';
import * as net from 'net';
import * as process from 'process';
import * as sinon from 'sinon';

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

describe('IPv6 Addresses', () => {
let client;
let ipv6Hosts;
beforeEach(async function () {
if (
process.platform !== 'win32' &&
this.configuration.topologyType !== TopologyType.ReplicaSetWithPrimary
) {
// Ubuntu 18 does not support localhost AAAA lookups (IPv6)
// Windows (VS2019) does
return this.skip();
}

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

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

it('should successfully connect using ipv6', async () => {
await client.db().command({ ping: 1 });
expect(sorted(client.topology.s.description.servers.keys(), byStrings)).to.deep.equal(
ipv6Hosts
);
});

it('should connect using ipv6 in connection string', async () => {
const createConnectionSpy = sinon.spy(net, 'createConnection');
await client.db().command({ ping: 1 });

expect(createConnectionSpy).to.be.calledWithMatch({ host: '::1' });
expect(createConnectionSpy).to.not.be.calledWithMatch({ 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
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
4 changes: 3 additions & 1 deletion 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 @@ -71,6 +71,8 @@ export function executeUriValidationTest(
} catch (err) {
if (err instanceof TypeError) {
expect(err).to.have.property('code').equal('ERR_INVALID_URL');
} else 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 },
{ host: '::1', port: 27017, isIPv6: true },
{ host: '::1', port: 27017, isIPv6: true }
]);
});

it('should allow multiple bracketed portless localhost 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 },
{ host: 'abcd:f::abcd:abcd:abcd:abcd', port: 27017, isIPv6: true },
{ host: 'abcd:f::abcd:abcd:abcd:abcd', port: 27017, isIPv6: true }
]);
});

it('should allow multiple bracketed port-full IPv6 addresses', () => {
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 },
{ host: '::1', port: 27019, isIPv6: true },
{ host: '::1', port: 27020, isIPv6: true }
]);
});
});
});
12 changes: 9 additions & 3 deletions test/unit/sdam/server_description.test.ts
Expand Up @@ -63,9 +63,15 @@ describe('ServerDescription', function () {
}
});

it('should sensibly parse an ipv6 address', function () {
const description = new ServerDescription('[ABCD:f::abcd:abcd:abcd:abcd]:27017');
expect(description.host).to.equal('abcd:f::abcd:abcd:abcd:abcd');
it('should normalize an IPv6 address with brackets and toLowered characters', function () {
const description = new ServerDescription('[ABCD:f::abcd:abcd:abcd:abcd]:1234');
expect(description.host).to.equal('[abcd:f::abcd:abcd:abcd:abcd]'); // IPv6 Addresses must always be bracketed if there is a port
expect(description.port).to.equal(1234);
});

it('should normalize an IPv6 address with brackets and toLowered characters even when the port is omitted', function () {
const description = new ServerDescription('[ABCD:f::abcd:abcd:abcd:abcd]');
expect(description.host).to.equal('[abcd:f::abcd:abcd:abcd:abcd]');
expect(description.port).to.equal(27017);
});

Expand Down

0 comments on commit b203159

Please sign in to comment.