diff --git a/.evergreen/config.yml b/.evergreen/config.yml index ad682113dc..aa2c836b66 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -293,6 +293,54 @@ functions: export NODE_LTS_NAME='${NODE_LTS_NAME}' bash ${PROJECT_DIRECTORY}/.evergreen/run-atlas-tests.sh + run socks5 tests: + - command: shell.exec + type: test + params: + silent: true + working_dir: src + script: | + ${PREPARE_SHELL} + cat < prepare_client_encryption.sh + export CLIENT_ENCRYPTION=${CLIENT_ENCRYPTION} + export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' + export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" + export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" + export CSFLE_GIT_REF="${CSFLE_GIT_REF}" + export CDRIVER_GIT_REF="${CDRIVER_GIT_REF}" + EOT + - command: shell.exec + type: test + params: + working_dir: src + script: > + ${PREPARE_SHELL} + + + export PYTHON_BINARY=$([ "Windows_NT" = "$OS" ] && echo "/cygdrive/c/python/python38/python.exe" || echo + "/opt/mongodbtoolchain/v3/bin/python3") + + export PROJECT_DIRECTORY="$(pwd)" + + export DRIVERS_TOOLS="${DRIVERS_TOOLS}" + + export NODE_LTS_NAME='${NODE_LTS_NAME}' + + export MONGODB_URI="${MONGODB_URI}" + + export SSL="${SSL}" + + + # Disable xtrace (just in case it was accidentally set). + + set +x + + . ./prepare_client_encryption.sh + + rm -f ./prepare_client_encryption.sh + + + bash ${PROJECT_DIRECTORY}/.evergreen/run-socks5-tests.sh run kerberos tests: - command: shell.exec type: test @@ -907,6 +955,27 @@ tasks: commands: - func: install dependencies - func: run ldap tests + - name: test-socks5 + tags: [] + commands: + - func: install dependencies + - func: bootstrap mongo-orchestration + vars: + VERSION: latest + TOPOLOGY: replica_set + - func: run socks5 tests + - name: test-socks5-tls + tags: [] + commands: + - func: install dependencies + - func: bootstrap mongo-orchestration + vars: + SSL: ssl + VERSION: latest + TOPOLOGY: replica_set + - func: run socks5 tests + vars: + SSL: ssl - name: test-ocsp-valid-cert-server-staples tags: - ocsp @@ -1683,6 +1752,8 @@ buildvariants: - test-atlas-data-lake - test-auth-kerberos - test-auth-ldap + - test-socks5 + - test-socks5-tls - test-ocsp-valid-cert-server-staples - test-ocsp-invalid-cert-server-staples - test-ocsp-valid-cert-server-does-not-staple @@ -1753,6 +1824,8 @@ buildvariants: - test-load-balancer - test-auth-kerberos - test-auth-ldap + - test-socks5 + - test-socks5-tls - test-ocsp-valid-cert-server-staples - test-ocsp-invalid-cert-server-staples - test-ocsp-valid-cert-server-does-not-staple @@ -1819,6 +1892,8 @@ buildvariants: - test-3.6-sharded_cluster - test-latest-server-v1-api - test-atlas-data-lake + - test-socks5 + - test-socks5-tls - test-ocsp-valid-cert-server-staples - test-ocsp-invalid-cert-server-staples - test-ocsp-valid-cert-server-does-not-staple diff --git a/.evergreen/config.yml.in b/.evergreen/config.yml.in index 5aca7f116d..794a252b2b 100644 --- a/.evergreen/config.yml.in +++ b/.evergreen/config.yml.in @@ -326,6 +326,43 @@ functions: bash ${PROJECT_DIRECTORY}/.evergreen/run-atlas-tests.sh + "run socks5 tests": + - command: shell.exec + type: test + params: + silent: true + working_dir: "src" + script: | + ${PREPARE_SHELL} + cat < prepare_client_encryption.sh + export CLIENT_ENCRYPTION=${CLIENT_ENCRYPTION} + export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' + export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" + export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" + export CSFLE_GIT_REF="${CSFLE_GIT_REF}" + export CDRIVER_GIT_REF="${CDRIVER_GIT_REF}" + EOT + - command: shell.exec + type: test + params: + working_dir: "src" + script: | + ${PREPARE_SHELL} + + export PYTHON_BINARY=$([ "Windows_NT" = "$OS" ] && echo "/cygdrive/c/python/python38/python.exe" || echo "/opt/mongodbtoolchain/v3/bin/python3") + export PROJECT_DIRECTORY="$(pwd)" + export DRIVERS_TOOLS="${DRIVERS_TOOLS}" + export NODE_LTS_NAME='${NODE_LTS_NAME}' + export MONGODB_URI="${MONGODB_URI}" + export SSL="${SSL}" + + # Disable xtrace (just in case it was accidentally set). + set +x + . ./prepare_client_encryption.sh + rm -f ./prepare_client_encryption.sh + + bash ${PROJECT_DIRECTORY}/.evergreen/run-socks5-tests.sh + "run kerberos tests": - command: shell.exec type: test diff --git a/.evergreen/generate_evergreen_tasks.js b/.evergreen/generate_evergreen_tasks.js index 1693b36826..21d842a783 100644 --- a/.evergreen/generate_evergreen_tasks.js +++ b/.evergreen/generate_evergreen_tasks.js @@ -136,6 +136,37 @@ TASKS.push( tags: ['auth', 'ldap'], commands: [{ func: 'install dependencies' }, { func: 'run ldap tests' }] }, + { + name: 'test-socks5', + tags: [], + commands: [ + { func: 'install dependencies' }, + { + func: 'bootstrap mongo-orchestration', + vars: { + VERSION: 'latest', + TOPOLOGY: 'replica_set' + } + }, + { func: 'run socks5 tests' } + ] + }, + { + name: 'test-socks5-tls', + tags: [], + commands: [ + { func: 'install dependencies' }, + { + func: 'bootstrap mongo-orchestration', + vars: { + SSL: 'ssl', + VERSION: 'latest', + TOPOLOGY: 'replica_set' + } + }, + { func: 'run socks5 tests', vars: { SSL: 'ssl' } } + ] + }, { name: 'test-ocsp-valid-cert-server-staples', tags: ['ocsp'], diff --git a/.evergreen/run-custom-csfle-tests.sh b/.evergreen/run-custom-csfle-tests.sh index 33582b0057..7ef02df755 100644 --- a/.evergreen/run-custom-csfle-tests.sh +++ b/.evergreen/run-custom-csfle-tests.sh @@ -1,5 +1,7 @@ #! /usr/bin/env bash +set +o xtrace # Do not write AWS credentials to stderr + # Initiail checks for running these tests if [ -z ${AWS_ACCESS_KEY_ID+omitted} ]; then echo "AWS_ACCESS_KEY_ID is unset" && exit 1; fi if [ -z ${AWS_SECRET_ACCESS_KEY+omitted} ]; then echo "AWS_SECRET_ACCESS_KEY is unset" && exit 1; fi @@ -38,12 +40,14 @@ git clone https://github.com/mongodb/libmongocrypt.git pushd libmongocrypt git fetch --tags git checkout "$CSFLE_GIT_REF" -b csfle-custom +echo "checked out libmongocrypt at $(git rev-parse HEAD)" popd # libmongocrypt git clone https://github.com/mongodb/mongo-c-driver.git pushd mongo-c-driver git fetch --tags git checkout "$CDRIVER_GIT_REF" -b cdriver-custom +echo "checked out C driver at $(git rev-parse HEAD)" popd # mongo-c-driver pushd libmongocrypt/bindings/node diff --git a/.evergreen/run-socks5-tests.sh b/.evergreen/run-socks5-tests.sh new file mode 100644 index 0000000000..22479ff2d2 --- /dev/null +++ b/.evergreen/run-socks5-tests.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +source "${PROJECT_DIRECTORY}/.evergreen/init-nvm.sh" + +set -o errexit # Exit the script with error if any of the commands fail +set -o xtrace # For debuggability, no external credentials are used here + +PYTHON_BINARY=${PYTHON_BINARY:-python3} + +# ssl setup +SSL=${SSL:-nossl} +if [ "$SSL" != "nossl" ]; then + export SSL_KEY_FILE="$DRIVERS_TOOLS/.evergreen/x509gen/client.pem" + export SSL_CA_FILE="$DRIVERS_TOOLS/.evergreen/x509gen/ca.pem" +fi + +# Grab a connection string that only refers to *one* of the hosts in MONGODB_URI +FIRST_HOST=$(node -p 'new (require("mongodb-connection-string-url").default)(process.env.MONGODB_URI).hosts[0]') +# Use localhost:12345 as the URL for the single host that we connect to, +# we configure the Socks5 proxy server script to redirect from this to FIRST_HOST +export MONGODB_URI_SINGLEHOST="mongodb://localhost:12345/" + +# Compute path to socks5 fake server script in a way that works on Windows +SOCKS5_SERVER_SCRIPT="$DRIVERS_TOOLS/.evergreen/socks5srv.py" +if [ "Windows_NT" = "$OS" ]; then + SOCKS5_SERVER_SCRIPT=$(cygpath -w "$SOCKS5_SERVER_SCRIPT") +fi + +# First, test with Socks5 + authentication required +"$PYTHON_BINARY" "$SOCKS5_SERVER_SCRIPT" --port 1080 --auth username:p4ssw0rd --map "localhost:12345 to $FIRST_HOST" & +PID=$! +env SOCKS5_CONFIG='["localhost",1080,"username","p4ssw0rd"]' npm run check:socks5 +[ "$SSL" == "nossl" ] && [[ "$OSTYPE" == "linux-gnu"* ]] && \ + env MONGODB_URI='mongodb://localhost:12345/?proxyHost=localhost&proxyUsername=username&proxyPassword=p4ssw0rd' \ + bash "${PROJECT_DIRECTORY}/.evergreen/run-custom-csfle-tests.sh" +kill $PID + +# Second, test with Socks5 + no authentication +"$PYTHON_BINARY" "$SOCKS5_SERVER_SCRIPT" --port 1081 --map "localhost:12345 to $FIRST_HOST" & +PID=$! +env SOCKS5_CONFIG='["localhost",1081]' npm run check:socks5 +[ "$SSL" == "nossl" ] && [[ "$OSTYPE" == "linux-gnu"* ]] && \ + env MONGODB_URI='mongodb://localhost:12345/?proxyHost=localhost&proxyPort=1081' \ + bash "${PROJECT_DIRECTORY}/.evergreen/run-custom-csfle-tests.sh" +kill $PID + +# TODO: It might be worth using something more robust to control +# the Socks5 proxy server script's lifetime diff --git a/package-lock.json b/package-lock.json index f4a3709e90..a40486150c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "bson": "^4.6.0", "denque": "^2.0.1", - "mongodb-connection-string-url": "^2.3.2" + "mongodb-connection-string-url": "^2.3.2", + "socks": "^2.6.1" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -3803,6 +3804,11 @@ "node": ">= 0.10" } }, + "node_modules/ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" + }, "node_modules/irregular-plurals": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.3.0.tgz", @@ -6071,6 +6077,28 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.1.tgz", + "integrity": "sha512-kLQ9N5ucj8uIcxrDwjm0Jsqk06xdpBjGNQtpXy4Q8/QY2k+fY7nZH8CARy+hkbG+SGAovmzzuauCpBlb8FrnBA==", + "dependencies": { + "ip": "^1.1.5", + "smart-buffer": "^4.1.0" + }, + "engines": { + "node": ">= 10.13.0", + "npm": ">= 3.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10245,6 +10273,11 @@ "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" + }, "irregular-plurals": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.3.0.tgz", @@ -11932,6 +11965,20 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==" + }, + "socks": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.1.tgz", + "integrity": "sha512-kLQ9N5ucj8uIcxrDwjm0Jsqk06xdpBjGNQtpXy4Q8/QY2k+fY7nZH8CARy+hkbG+SGAovmzzuauCpBlb8FrnBA==", + "requires": { + "ip": "^1.1.5", + "smart-buffer": "^4.1.0" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index f277d3f745..552f404e42 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "dependencies": { "bson": "^4.6.0", "denque": "^2.0.1", - "mongodb-connection-string-url": "^2.3.2" + "mongodb-connection-string-url": "^2.3.2", + "socks": "^2.6.1" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -113,6 +114,7 @@ "check:kerberos": "mocha --config \"test/manual/mocharc.json\" test/manual/kerberos.test.js", "check:tls": "mocha --config \"test/manual/mocharc.json\" test/manual/tls_support.test.js", "check:ldap": "mocha --config \"test/manual/mocharc.json\" test/manual/ldap.test.js", + "check:socks5": "mocha --config \"test/manual/mocharc.json\" test/manual/socks5.test.ts", "check:csfle": "mocha --file test/tools/runner test/integration/client-side-encryption", "check:snappy": "mocha --file test/tools/runner test/functional/unit_snappy.test.js", "prepare": "node etc/prepare.js", diff --git a/src/cmap/connect.ts b/src/cmap/connect.ts index ba2f2c7f41..622fd391ec 100644 --- a/src/cmap/connect.ts +++ b/src/cmap/connect.ts @@ -1,5 +1,6 @@ import type { Socket, SocketConnectOpts } from 'net'; import * as net from 'net'; +import { SocksClient } from 'socks'; import type { ConnectionOptions as TLSConnectionOpts, TLSSocket } from 'tls'; import * as tls from 'tls'; @@ -14,7 +15,14 @@ import { MongoRuntimeError, MongoServerError } from '../error'; -import { Callback, CallbackWithType, ClientMetadata, makeClientMetadata, ns } from '../utils'; +import { + Callback, + CallbackWithType, + ClientMetadata, + HostAddress, + makeClientMetadata, + ns +} from '../utils'; import { AuthContext, AuthProvider } from './auth/auth_provider'; import { GSSAPI } from './auth/gssapi'; import { MongoCR } from './auth/mongocr'; @@ -49,7 +57,7 @@ const FAKE_MONGODB_SERVICE_ID = export type Stream = Socket | TLSSocket; export function connect(options: ConnectionOptions, callback: Callback): void { - makeConnection(options, (err, socket) => { + makeConnection({ ...options, existingSocket: undefined }, (err, socket) => { if (err || !socket) { return callback(err); } @@ -305,7 +313,9 @@ function parseConnectOptions(options: ConnectionOptions): SocketConnectOpts { } } -function parseSslOptions(options: ConnectionOptions): TLSConnectionOpts { +type MakeConnectionOptions = ConnectionOptions & { existingSocket?: Stream }; + +function parseSslOptions(options: MakeConnectionOptions): TLSConnectionOpts { const result: TLSConnectionOpts = parseConnectOptions(options); // Merge in valid SSL options for (const name of LEGAL_TLS_SOCKET_OPTIONS) { @@ -314,6 +324,10 @@ function parseSslOptions(options: ConnectionOptions): TLSConnectionOpts { } } + if (options.existingSocket) { + result.socket = options.existingSocket; + } + // Set default sni servername to be the same as host if (result.servername == null && result.host && !net.isIP(result.host)) { result.servername = result.host; @@ -326,17 +340,21 @@ const SOCKET_ERROR_EVENT_LIST = ['error', 'close', 'timeout', 'parseError'] as c type ErrorHandlerEventName = typeof SOCKET_ERROR_EVENT_LIST[number] | 'cancel'; const SOCKET_ERROR_EVENTS = new Set(SOCKET_ERROR_EVENT_LIST); -function makeConnection(options: ConnectionOptions, _callback: CallbackWithType) { +function makeConnection( + options: MakeConnectionOptions, + _callback: CallbackWithType +) { const useTLS = options.tls ?? false; const keepAlive = options.keepAlive ?? true; const socketTimeoutMS = options.socketTimeoutMS ?? Reflect.get(options, 'socketTimeout') ?? 0; const noDelay = options.noDelay ?? true; - const connectionTimeout = options.connectTimeoutMS ?? 30000; + const connectTimeoutMS = options.connectTimeoutMS ?? 30000; const rejectUnauthorized = options.rejectUnauthorized ?? true; const keepAliveInitialDelay = ((options.keepAliveInitialDelay ?? 120000) > socketTimeoutMS ? Math.round(socketTimeoutMS / 2) : options.keepAliveInitialDelay) ?? 120000; + const existingSocket = options.existingSocket; let socket: Stream; const callback: Callback = function (err, ret) { @@ -347,18 +365,34 @@ function makeConnection(options: ConnectionOptions, _callback: CallbackWithType< _callback(err, ret); }; + if (options.proxyHost != null) { + // Currently, only Socks5 is supported. + return makeSocks5Connection( + { + ...options, + connectTimeoutMS // Should always be present for Socks5 + }, + callback + ); + } + if (useTLS) { const tlsSocket = tls.connect(parseSslOptions(options)); if (typeof tlsSocket.disableRenegotiation === 'function') { tlsSocket.disableRenegotiation(); } socket = tlsSocket; + } else if (existingSocket) { + // In the TLS case, parseSslOptions() sets options.socket to existingSocket, + // so we only need to handle the non-TLS case here (where existingSocket + // gives us all we need out of the box). + socket = existingSocket; } else { socket = net.createConnection(parseConnectOptions(options)); } socket.setKeepAlive(keepAlive, keepAliveInitialDelay); - socket.setTimeout(connectionTimeout); + socket.setTimeout(connectTimeoutMS); socket.setNoDelay(noDelay); const connectEvent = useTLS ? 'secureConnect' : 'connect'; @@ -397,10 +431,80 @@ function makeConnection(options: ConnectionOptions, _callback: CallbackWithType< options.cancellationToken.once('cancel', cancellationHandler); } - socket.once(connectEvent, connectHandler); + if (existingSocket) { + process.nextTick(connectHandler); + } else { + socket.once(connectEvent, connectHandler); + } +} + +function makeSocks5Connection(options: MakeConnectionOptions, callback: Callback) { + const hostAddress = HostAddress.fromHostPort( + options.proxyHost ?? '', // proxyHost is guaranteed to set here + options.proxyPort ?? 1080 + ); + + // First, connect to the proxy server itself: + makeConnection( + { + ...options, + hostAddress, + tls: false, + proxyHost: undefined + }, + (err, rawSocket) => { + if (err) { + return callback(err); + } + + const destination = parseConnectOptions(options) as net.TcpNetConnectOpts; + if (typeof destination.host !== 'string' || typeof destination.port !== 'number') { + return callback( + new MongoInvalidArgumentError('Can only make Socks5 connections to TCP hosts') + ); + } + + // Then, establish the Socks5 proxy connection: + SocksClient.createConnection( + { + existing_socket: rawSocket, + timeout: options.connectTimeoutMS, + command: 'connect', + destination: { + host: destination.host, + port: destination.port + }, + proxy: { + // host and port are ignored because we pass existing_socket + host: 'iLoveJavaScript', + port: 0, + type: 5, + userId: options.proxyUsername || undefined, + password: options.proxyPassword || undefined + } + }, + (err: AnyError, info: { socket: Stream }) => { + if (err) { + return callback(connectionFailureError('error', err)); + } + + // Finally, now treat the resulting duplex stream as the + // socket over which we send and receive wire protocol messages: + makeConnection( + { + ...options, + existingSocket: info.socket, + proxyHost: undefined + }, + callback + ); + } + ); + } + ); } -function connectionFailureError(type: string, err: Error) { +function connectionFailureError(type: ErrorHandlerEventName, err: Error) { switch (type) { case 'error': return new MongoNetworkError(err); diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index 77e106d666..d062e2633f 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -124,10 +124,19 @@ export interface GetMoreOptions extends CommandOptions { comment?: Document | string; } +/** @public */ +export interface ProxyOptions { + proxyHost?: string; + proxyPort?: number; + proxyUsername?: string; + proxyPassword?: string; +} + /** @public */ export interface ConnectionOptions extends SupportedNodeConnectionOptions, - StreamDescriptionOptions { + StreamDescriptionOptions, + ProxyOptions { // Internal creation info id: number | ''; generation: number; @@ -216,7 +225,7 @@ export class Connection extends TypedEventEmitter { constructor(stream: Stream, options: ConnectionOptions) { super(); this.id = options.id; - this.address = streamIdentifier(stream); + this.address = streamIdentifier(stream, options); this.socketTimeoutMS = options.socketTimeoutMS ?? 0; this.monitorCommands = options.monitorCommands; this.serverApi = options.serverApi; @@ -757,7 +766,13 @@ function messageHandler(conn: Connection) { }; } -function streamIdentifier(stream: Stream) { +function streamIdentifier(stream: Stream, options: ConnectionOptions): string { + if (options.proxyHost) { + // If proxy options are specified, the properties of `stream` itself + // will not accurately reflect what endpoint this is connected to. + return options.hostAddress.toString(); + } + if (typeof stream.address === 'function') { return `${stream.remoteAddress}:${stream.remotePort}`; } diff --git a/src/connection_string.ts b/src/connection_string.ts index 77f412b71c..079dfd3f51 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -454,6 +454,31 @@ export function parseOptions( } } + if ( + !mongoOptions.proxyHost && + (mongoOptions.proxyPort || mongoOptions.proxyUsername || mongoOptions.proxyPassword) + ) { + throw new MongoParseError('Must specify proxyHost if other proxy options are passed'); + } + + if ( + (mongoOptions.proxyUsername && !mongoOptions.proxyPassword) || + (!mongoOptions.proxyUsername && mongoOptions.proxyPassword) + ) { + throw new MongoParseError('Can only specify both of proxy username/password or neither'); + } + + if ( + urlOptions.get('proxyHost')?.length > 1 || + urlOptions.get('proxyPort')?.length > 1 || + urlOptions.get('proxyUsername')?.length > 1 || + urlOptions.get('proxyPassword')?.length > 1 + ) { + throw new MongoParseError( + 'Proxy options cannot be specified multiple times in the connection string' + ); + } + return mongoOptions; } @@ -860,6 +885,18 @@ export const OPTIONS = { promoteValues: { type: 'boolean' }, + proxyHost: { + type: 'string' + }, + proxyPassword: { + type: 'string' + }, + proxyPort: { + type: 'uint' + }, + proxyUsername: { + type: 'string' + }, raw: { default: false, type: 'boolean' diff --git a/src/deps.ts b/src/deps.ts index 1d3a4821b0..be1f386c0a 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import type { deserialize, Document, serialize } from './bson'; +import type { ProxyOptions } from './cmap/connection'; import { MongoMissingDependencyError } from './error'; import type { MongoClient } from './mongo_client'; import { Callback, parsePackageVersion } from './utils'; @@ -262,6 +263,7 @@ export interface AutoEncryptionOptions { /** Command line arguments to use when auto-spawning a mongocryptd */ mongocryptdSpawnArgs?: string[]; }; + proxyOptions?: ProxyOptions; } /** @public */ diff --git a/src/encrypter.ts b/src/encrypter.ts index 5fd97019e2..61b1964d0c 100644 --- a/src/encrypter.ts +++ b/src/encrypter.ts @@ -46,6 +46,15 @@ export class Encrypter { options.autoEncryption.metadataClient = this.getInternalClient(client, uri, options); } + if (options.proxyHost) { + options.autoEncryption.proxyOptions = { + proxyHost: options.proxyHost, + proxyPort: options.proxyPort, + proxyUsername: options.proxyUsername, + proxyPassword: options.proxyPassword + }; + } + options.autoEncryption.bson = Object.create(null); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion options.autoEncryption.bson!.serialize = serialize; diff --git a/src/index.ts b/src/index.ts index e3a53fdbc2..eb7cc75f97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,6 +198,7 @@ export type { ConnectionOptions, DestroyOptions, GetMoreOptions, + ProxyOptions, QueryOptions } from './cmap/connection'; export type { diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 770595b236..73c34503dc 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -248,6 +248,14 @@ export interface MongoClientOptions extends BSONSerializeOptions, SupportedNodeC autoEncryption?: AutoEncryptionOptions; /** Allows a wrapping driver to amend the client metadata generated by the driver to include information about the wrapping driver */ driverInfo?: DriverInfo; + /** Configures a Socks5 proxy host used for creating TCP connections. */ + proxyHost?: string; + /** Configures a Socks5 proxy port used for creating TCP connections. */ + proxyPort?: number; + /** Configures a Socks5 proxy username when the proxy in proxyHost requires username/password authentication. */ + proxyUsername?: string; + /** Configures a Socks5 proxy password when the proxy in proxyHost requires username/password authentication. */ + proxyPassword?: string; /** @internal */ srvPoller?: SrvPoller; @@ -677,6 +685,10 @@ export interface MongoOptions dbName: string; metadata: ClientMetadata; autoEncrypter?: AutoEncrypter; + proxyHost?: string; + proxyPort?: number; + proxyUsername?: string; + proxyPassword?: string; /** @internal */ connectionType?: typeof Connection; diff --git a/src/utils.ts b/src/utils.ts index d950d148d7..e4f8898fc8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1356,8 +1356,15 @@ export class HostAddress { return new HostAddress(s); } + static fromHostPort(host: string, port: number): HostAddress { + if (host.includes(':')) { + host = `[${host}]`; // IPv6 address + } + return HostAddress.fromString(`${host}:${port}`); + } + static fromSrvRecord({ name, port }: SrvRecord): HostAddress { - return HostAddress.fromString(`${name}:${port}`); + return HostAddress.fromHostPort(name, port); } } diff --git a/test/manual/socks5.test.ts b/test/manual/socks5.test.ts new file mode 100644 index 0000000000..429d6f90d2 --- /dev/null +++ b/test/manual/socks5.test.ts @@ -0,0 +1,309 @@ +import { expect } from 'chai'; +import ConnectionString from 'mongodb-connection-string-url'; + +import { MongoClient } from '../../src'; +import { MongoParseError } from '../../src/error'; + +/** + * The SOCKS5_CONFIG environment variable is either a JSON 4-tuple + * [host, port, username, password] or just [host, port]. + */ + +describe('Socks5 Connectivity', function () { + if (!process.env.SOCKS5_CONFIG == null) { + console.error('skipping Socks5 tests, SOCKS5_CONFIG environment variable is not defined'); + + return; + } + + this.timeout(10000); + + const [proxyHost, proxyPort, proxyUsername, proxyPassword] = JSON.parse( + process.env.SOCKS5_CONFIG + ); + const rsConnectionString = new ConnectionString(process.env.MONGODB_URI); + const singleConnectionString = new ConnectionString(process.env.MONGODB_URI_SINGLEHOST); + + if (process.env.SSL === 'ssl') { + rsConnectionString.searchParams.set('tls', 'true'); + rsConnectionString.searchParams.set('tlsCAFile', process.env.SSL_CA_FILE); + singleConnectionString.searchParams.set('tls', 'true'); + singleConnectionString.searchParams.set('tlsCAFile', process.env.SSL_CA_FILE); + } + rsConnectionString.searchParams.set('serverSelectionTimeoutMS', '2000'); + singleConnectionString.searchParams.set('serverSelectionTimeoutMS', '2000'); + + context((proxyUsername ? 'with' : 'without') + ' Socks5 auth required', function () { + context('with missing required Socks5 auth configuration', function () { + if (!proxyUsername) { + beforeEach(function () { + this.skip(); + }); + } + + it('fails to connect to a single host (connection string)', async function () { + const cs = singleConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + cs.searchParams.set('directConnection', 'true'); + try { + await testConnection(cs.toString(), {}); + } catch (err) { + expect(err.name).to.equal('MongoServerSelectionError'); + expect(err.message).to.match(/Received invalid Socks5 initial handshake/); + return; + } + expect.fail('missed exception'); + }); + + it('fails to connect to a single host (config options)', async function () { + try { + await testConnection(singleConnectionString.toString(), { + proxyHost, + proxyPort, + directConnection: true + }); + } catch (err) { + expect(err.name).to.equal('MongoServerSelectionError'); + expect(err.message).to.match(/Received invalid Socks5 initial handshake/); + return; + } + expect.fail('missed exception'); + }); + + it('fails to connect to a replica set (connection string)', async function () { + const cs = rsConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + try { + await testConnection(cs.toString(), {}); + } catch (err) { + expect(err.name).to.equal('MongoServerSelectionError'); + expect(err.message).to.match(/Received invalid Socks5 initial handshake/); + return; + } + expect.fail('missed exception'); + }); + + it('fails to connect to a replica set (config options)', async function () { + try { + await testConnection(rsConnectionString.toString(), { + proxyHost, + proxyPort + }); + } catch (err) { + expect(err.name).to.equal('MongoServerSelectionError'); + expect(err.message).to.match(/Received invalid Socks5 initial handshake/); + return; + } + expect.fail('missed exception'); + }); + + it('fails to connect to a single host (connection string) if auth is present but wrong', async function () { + const cs = singleConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + cs.searchParams.set('proxyUsername', 'nonexistentuser'); + cs.searchParams.set('proxyPassword', 'badauth'); + cs.searchParams.set('directConnection', 'true'); + try { + await testConnection(cs.toString(), {}); + } catch (err) { + expect(err.name).to.equal('MongoServerSelectionError'); + expect(err.message).to.match(/Socket closed/); + return; + } + expect.fail('missed exception'); + }); + }); + + context('with extraneous Socks5 auth configuration', function () { + if (proxyUsername) { + beforeEach(function () { + this.skip(); + }); + } + + it('can connect to a single host (connection string)', async function () { + const cs = singleConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + cs.searchParams.set('proxyUsername', 'nonexistentuser'); + cs.searchParams.set('proxyPassword', 'badauth'); + cs.searchParams.set('directConnection', 'true'); + await testConnection(cs.toString(), {}); + }); + + it('can connect to a single host (config options)', async function () { + await testConnection(singleConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? {} + : { + proxyUsername: 'nonexistentuser', + proxyPassword: 'badauth' + }), + directConnection: true + }); + }); + + it('can connect to a replica set (connection string)', async function () { + const cs = rsConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + cs.searchParams.set('proxyUsername', 'nonexistentuser'); + cs.searchParams.set('proxyPassword', 'badauth'); + await testConnection(cs.toString(), {}); + }); + + it('can connect to a replica set (config options)', async function () { + await testConnection(rsConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? {} + : { + proxyUsername: 'nonexistentuser', + proxyPassword: 'badauth' + }) + }); + }); + }); + + context('with matching socks5 authentication', () => { + it('can connect to a single host (connection string, with directConnection)', async function () { + const cs = singleConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + if (proxyUsername) { + cs.searchParams.set('proxyUsername', proxyUsername); + cs.searchParams.set('proxyPassword', proxyPassword); + } + cs.searchParams.set('directConnection', 'true'); + expect(await testConnection(cs.toString(), {})).to.equal('Single'); + }); + + it('can connect to a single host (config options, with directConnection)', async function () { + expect( + await testConnection(singleConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? { + proxyUsername, + proxyPassword + } + : {}), + directConnection: true + }) + ).to.equal('Single'); + }); + + it('can connect to a single host (connection string, without directConnection)', async function () { + const cs = singleConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + if (proxyUsername) { + cs.searchParams.set('proxyUsername', proxyUsername); + cs.searchParams.set('proxyPassword', proxyPassword); + } + cs.searchParams.set('directConnection', 'false'); + expect(await testConnection(cs.toString(), {})).to.equal('ReplicaSetWithPrimary'); + }); + + it('can connect to a single host (config options, without directConnection)', async function () { + expect( + await testConnection(singleConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? { + proxyUsername, + proxyPassword + } + : {}), + directConnection: false + }) + ).to.equal('ReplicaSetWithPrimary'); + }); + + it('can connect to a replica set (connection string)', async function () { + const cs = rsConnectionString.clone(); + cs.searchParams.set('proxyHost', proxyHost); + cs.searchParams.set('proxyPort', String(proxyPort)); + if (proxyUsername) { + cs.searchParams.set('proxyUsername', proxyUsername); + cs.searchParams.set('proxyPassword', proxyPassword); + } + expect(await testConnection(cs.toString(), {})).to.equal('ReplicaSetWithPrimary'); + }); + + it('can connect to a replica set (config options)', async function () { + expect( + await testConnection(rsConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? { + proxyUsername, + proxyPassword + } + : {}) + }) + ).to.equal('ReplicaSetWithPrimary'); + }); + + it('does not mention the proxy in command monitoring events', async function () { + const client = new MongoClient(singleConnectionString.toString(), { + proxyHost, + proxyPort, + ...(proxyUsername + ? { + proxyUsername, + proxyPassword + } + : {}), + directConnection: true, + monitorCommands: true + }); + const seenCommandAddresses = new Set(); + client.on('commandSucceeded', ev => seenCommandAddresses.add(ev.address)); + + await client.connect(); + await client.db('admin').command({ ismaster: 1 }); + await client.close(); + expect([...seenCommandAddresses]).to.deep.equal(singleConnectionString.hosts); + }); + }); + }); + + context('MongoClient option validation', () => { + for (const proxyOptions of [ + { proxyPort: 1080 }, + { proxyUsername: 'abc' }, + { proxyPassword: 'def' }, + { proxyPort: 1080, proxyUsername: 'abc', proxyPassword: 'def' }, + { proxyHost: 'localhost', proxyUsername: 'abc' }, + { proxyHost: 'localhost', proxyPassword: 'def' } + ]) { + it(`rejects invalid MongoClient options ${JSON.stringify(proxyOptions)}`, () => { + expect(() => new MongoClient('mongodb://localhost', proxyOptions)).to.throw( + MongoParseError + ); + }); + } + }); +}); + +async function testConnection(connectionString, clientOptions) { + const client = new MongoClient(connectionString, clientOptions); + let topologyType; + client.on('topologyDescriptionChanged', ev => (topologyType = ev.newDescription.type)); + + await client.connect(); + await client.db('admin').command({ hello: 1 }); + await client.db('test').collection('test').findOne({}); + await client.close(); + return topologyType; +} diff --git a/test/spec/uri-options/proxy-options.json b/test/spec/uri-options/proxy-options.json new file mode 100644 index 0000000000..4f07d70d9b --- /dev/null +++ b/test/spec/uri-options/proxy-options.json @@ -0,0 +1,139 @@ +{ + "tests": [ + { + "description": "proxyPort without proxyHost", + "uri": "mongodb://localhost/?proxyPort=1080", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "proxyUsername without proxyHost", + "uri": "mongodb://localhost/?proxyUsername=abc", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "proxyPassword without proxyHost", + "uri": "mongodb://localhost/?proxyPassword=def", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "all other proxy options without proxyHost", + "uri": "mongodb://localhost/?proxyPort=1080&proxyUsername=abc&proxyPassword=def", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "proxyUsername without proxyPassword", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "proxyPassword without proxyUsername", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPassword=def", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "multiple proxyHost parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyHost=localhost2", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "multiple proxyPort parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPort=1234&proxyPort=12345", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "multiple proxyUsername parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyUsername=def&proxyPassword=123", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "multiple proxyPassword parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyPassword=123&proxyPassword=456", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "only host present", + "uri": "mongodb://localhost/?proxyHost=localhost", + "valid": true, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "host and default port present", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPort=1080", + "valid": true, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "host and non-default port present", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPort=12345", + "valid": true, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "replicaset, host and non-default port present", + "uri": "mongodb://rs1,rs2,rs3/?proxyHost=localhost&proxyPort=12345", + "valid": true, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + }, + { + "description": "all options present", + "uri": "mongodb://rs1,rs2,rs3/?proxyHost=localhost&proxyPort=12345&proxyUsername=asdf&proxyPassword=qwerty", + "valid": true, + "warning": false, + "hosts": null, + "auth": null, + "options": {} + } + ] +} diff --git a/test/spec/uri-options/proxy-options.yml b/test/spec/uri-options/proxy-options.yml new file mode 100644 index 0000000000..1c0f8b3717 --- /dev/null +++ b/test/spec/uri-options/proxy-options.yml @@ -0,0 +1,121 @@ +tests: + - + description: "proxyPort without proxyHost" + uri: "mongodb://localhost/?proxyPort=1080" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "proxyUsername without proxyHost" + uri: "mongodb://localhost/?proxyUsername=abc" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "proxyPassword without proxyHost" + uri: "mongodb://localhost/?proxyPassword=def" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "all other proxy options without proxyHost" + uri: "mongodb://localhost/?proxyPort=1080&proxyUsername=abc&proxyPassword=def" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "proxyUsername without proxyPassword" + uri: "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "proxyPassword without proxyUsername" + uri: "mongodb://localhost/?proxyHost=localhost&proxyPassword=def" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "multiple proxyHost parameters" + uri: "mongodb://localhost/?proxyHost=localhost&proxyHost=localhost2" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "multiple proxyPort parameters" + uri: "mongodb://localhost/?proxyHost=localhost&proxyPort=1234&proxyPort=12345" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "multiple proxyUsername parameters" + uri: "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyUsername=def&proxyPassword=123" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "multiple proxyPassword parameters" + uri: "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyPassword=123&proxyPassword=456" + valid: false + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "only host present" + uri: "mongodb://localhost/?proxyHost=localhost" + valid: true + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "host and default port present" + uri: "mongodb://localhost/?proxyHost=localhost&proxyPort=1080" + valid: true + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "host and non-default port present" + uri: "mongodb://localhost/?proxyHost=localhost&proxyPort=12345" + valid: true + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "replicaset, host and non-default port present" + uri: "mongodb://rs1,rs2,rs3/?proxyHost=localhost&proxyPort=12345" + valid: true + warning: false + hosts: ~ + auth: ~ + options: {} + - + description: "all options present" + uri: "mongodb://rs1,rs2,rs3/?proxyHost=localhost&proxyPort=12345&proxyUsername=asdf&proxyPassword=qwerty" + valid: true + warning: false + hosts: ~ + auth: ~ + options: {} diff --git a/test/tools/runner/config.js b/test/tools/runner/config.js index fd7f8337bc..2ae0bece2a 100644 --- a/test/tools/runner/config.js +++ b/test/tools/runner/config.js @@ -53,7 +53,15 @@ class TestConfiguration { host: hostAddresses[0].host, port: typeof hostAddresses[0].host === 'string' ? hostAddresses[0].port : undefined, db: url.pathname.slice(1) ? url.pathname.slice(1) : 'integration_tests', - replicaSet: url.searchParams.get('replicaSet') + replicaSet: url.searchParams.get('replicaSet'), + proxyURIParams: url.searchParams.get('proxyHost') + ? { + proxyHost: url.searchParams.get('proxyHost'), + proxyPort: url.searchParams.get('proxyPort'), + proxyUsername: url.searchParams.get('proxyUsername'), + proxyPassword: url.searchParams.get('proxyPassword') + } + : undefined }; if (url.username) { this.options.auth = { @@ -144,6 +152,14 @@ class TestConfiguration { Object.assign(dbOptions, { replicaSet: this.options.replicaSet }); } + if (this.options.proxyURIParams) { + for (const [name, value] of Object.entries(this.options.proxyURIParams)) { + if (value) { + dbOptions[name] = value; + } + } + } + // Flatten any options nested under `writeConcern` before we make the connection string if (dbOptions.writeConcern) { Object.assign(dbOptions, dbOptions.writeConcern); @@ -206,7 +222,12 @@ class TestConfiguration { * @param {UrlOptions} [options] - overrides and settings for URI generation */ url(options) { - options = { db: this.options.db, replicaSet: this.options.replicaSet, ...options }; + options = { + db: this.options.db, + replicaSet: this.options.replicaSet, + proxyURIParams: this.options.proxyURIParams, + ...options + }; const FILLER_HOST = 'fillerHost'; @@ -216,6 +237,14 @@ class TestConfiguration { url.searchParams.append('replicaSet', options.replicaSet); } + if (options.proxyURIParams) { + for (const [name, value] of Object.entries(options.proxyURIParams)) { + if (value) { + url.searchParams.append(name, value); + } + } + } + url.pathname = `/${options.db}`; if (options.username) url.username = options.username;