diff --git a/specifications/uri-options/tests/proxy-options.json b/specifications/uri-options/tests/proxy-options.json new file mode 100644 index 00000000000..585546ead7f --- /dev/null +++ b/specifications/uri-options/tests/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": null + }, + { + "description": "proxyUsername without proxyHost", + "uri": "mongodb://localhost/?proxyUsername=abc", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "proxyPassword without proxyHost", + "uri": "mongodb://localhost/?proxyPassword=def", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "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": null + }, + { + "description": "proxyUsername without proxyPassword", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "proxyPassword without proxyUsername", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPassword=def", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "multiple proxyHost parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyHost=localhost2", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "multiple proxyPort parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyPort=1234&proxyPort=12345", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "multiple proxyUsername parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyUsername=def&proxyPassword=123", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "description": "multiple proxyPassword parameters", + "uri": "mongodb://localhost/?proxyHost=localhost&proxyUsername=abc&proxyPassword=123&proxyPassword=456", + "valid": false, + "warning": false, + "hosts": null, + "auth": null, + "options": null + }, + { + "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/specifications/uri-options/tests/proxy-options.yml b/specifications/uri-options/tests/proxy-options.yml new file mode 100644 index 00000000000..a97863dd599 --- /dev/null +++ b/specifications/uri-options/tests/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/src/MongoDB.Driver/ClusterKey.cs b/src/MongoDB.Driver/ClusterKey.cs index d208a7b60e4..dec3b93bad6 100644 --- a/src/MongoDB.Driver/ClusterKey.cs +++ b/src/MongoDB.Driver/ClusterKey.cs @@ -17,6 +17,7 @@ using System.Collections.Generic; using System.Linq; using MongoDB.Driver.Core.Configuration; +using MongoDB.Driver.Core.Connections; using MongoDB.Driver.Core.Servers; using MongoDB.Shared; @@ -55,6 +56,7 @@ internal sealed class ClusterKey private readonly ServerMonitoringMode _serverMonitoringMode; private readonly TimeSpan _serverSelectionTimeout; private readonly TimeSpan _socketTimeout; + private readonly Socks5ProxySettings _socks5ProxySettings; private readonly int _srvMaxHosts; private readonly string _srvServiceName; private readonly SslSettings _sslSettings; @@ -93,6 +95,7 @@ public ClusterKey( ServerMonitoringMode serverMonitoringMode, TimeSpan serverSelectionTimeout, TimeSpan socketTimeout, + Socks5ProxySettings socks5ProxySettings, int srvMaxHosts, string srvServiceName, SslSettings sslSettings, @@ -129,6 +132,7 @@ public ClusterKey( _serverMonitoringMode = serverMonitoringMode; _serverSelectionTimeout = serverSelectionTimeout; _socketTimeout = socketTimeout; + _socks5ProxySettings = socks5ProxySettings; _srvMaxHosts = srvMaxHosts; _srvServiceName = srvServiceName; _sslSettings = sslSettings; @@ -169,6 +173,7 @@ public ClusterKey( public ServerMonitoringMode ServerMonitoringMode { get { return _serverMonitoringMode; } } public TimeSpan ServerSelectionTimeout { get { return _serverSelectionTimeout; } } public TimeSpan SocketTimeout { get { return _socketTimeout; } } + public Socks5ProxySettings Socks5ProxySettings { get { return _socks5ProxySettings; } } public int SrvMaxHosts { get { return _srvMaxHosts; } } public string SrvServiceName { get { return _srvServiceName; } } public SslSettings SslSettings { get { return _sslSettings; } } @@ -224,6 +229,7 @@ public override bool Equals(object obj) _serverMonitoringMode == rhs._serverMonitoringMode && _serverSelectionTimeout == rhs._serverSelectionTimeout && _socketTimeout == rhs._socketTimeout && + object.Equals(_socks5ProxySettings, rhs._socks5ProxySettings) && _srvMaxHosts == rhs._srvMaxHosts && _srvServiceName == rhs.SrvServiceName && object.Equals(_sslSettings, rhs._sslSettings) && diff --git a/src/MongoDB.Driver/ClusterRegistry.cs b/src/MongoDB.Driver/ClusterRegistry.cs index 3359cd4b612..e6763cf0ba8 100644 --- a/src/MongoDB.Driver/ClusterRegistry.cs +++ b/src/MongoDB.Driver/ClusterRegistry.cs @@ -171,7 +171,8 @@ private TcpStreamSettings ConfigureTcp(TcpStreamSettings settings, ClusterKey cl readTimeout: clusterKey.SocketTimeout, receiveBufferSize: clusterKey.ReceiveBufferSize, sendBufferSize: clusterKey.SendBufferSize, - writeTimeout: clusterKey.SocketTimeout); + writeTimeout: clusterKey.SocketTimeout, + socks5ProxySettings: clusterKey.Socks5ProxySettings); } internal IClusterInternal GetOrCreateCluster(ClusterKey clusterKey) diff --git a/src/MongoDB.Driver/Core/Configuration/ClusterBuilderExtensions.cs b/src/MongoDB.Driver/Core/Configuration/ClusterBuilderExtensions.cs index 3fe6c4a3da2..7e8c7a0b994 100644 --- a/src/MongoDB.Driver/Core/Configuration/ClusterBuilderExtensions.cs +++ b/src/MongoDB.Driver/Core/Configuration/ClusterBuilderExtensions.cs @@ -23,6 +23,7 @@ using MongoDB.Driver.Authentication; using MongoDB.Driver.Authentication.Gssapi; using MongoDB.Driver.Authentication.Oidc; +using MongoDB.Driver.Core.Connections; using MongoDB.Driver.Core.Events.Diagnostics; using MongoDB.Driver.Core.Misc; @@ -118,6 +119,16 @@ public static ClusterBuilder ConfigureWithConnectionString( builder = builder.ConfigureTcp(s => s.With(addressFamily: AddressFamily.InterNetworkV6)); } + if (connectionString.ProxyHost != null) + { + builder = builder.ConfigureTcp(s => s.With( + socks5ProxySettings: Socks5ProxySettings.Create( + connectionString.ProxyHost, + connectionString.ProxyPort, + connectionString.ProxyUsername, + connectionString.ProxyPassword))); + } + if (connectionString.SocketTimeout != null) { builder = builder.ConfigureTcp(s => s.With( diff --git a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs index 79349c035cc..eadc83aad9b 100644 --- a/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs +++ b/src/MongoDB.Driver/Core/Configuration/ConnectionString.cs @@ -92,6 +92,10 @@ public sealed class ConnectionString private string _replicaSet; private bool? _retryReads; private bool? _retryWrites; + private string _proxyHost; + private int? _proxyPort; + private string _proxyUsername; + private string _proxyPassword; private ConnectionStringScheme _scheme; private ServerMonitoringMode? _serverMonitoringMode; private TimeSpan? _serverSelectionTimeout; @@ -356,6 +360,26 @@ public string Password get { return _password; } } + /// + /// Gets the proxy host. + /// + public string ProxyHost => _proxyHost; + + /// + /// Gets the proxy port. + /// + public int? ProxyPort => _proxyPort; + + /// + /// Gets the proxy username. + /// + public string ProxyUsername => _proxyUsername; + + /// + /// Gets the proxy password. + /// + public string ProxyPassword => _proxyPassword; + /// /// Gets the read concern level. /// @@ -903,6 +927,29 @@ private void Parse() } } + if (string.IsNullOrEmpty(_proxyHost)) + { + if (_proxyPort is not null) + { + throw new MongoConfigurationException("proxyPort cannot be specified without proxyHost."); + } + + if (!string.IsNullOrEmpty(_proxyUsername)) + { + throw new MongoConfigurationException("proxyUsername cannot be specified without proxyHost."); + } + + if (!string.IsNullOrEmpty(_proxyPassword)) + { + throw new MongoConfigurationException("proxyPassword cannot be specified without proxyHost."); + } + } + + if (string.IsNullOrEmpty(_proxyUsername) != string.IsNullOrEmpty(_proxyPassword)) + { + throw new MongoConfigurationException("proxyUsername and proxyPassword must both be specified or neither."); + } + string ProtectConnectionString(string connectionString) { var protectedString = Regex.Replace(connectionString, @"(?<=://)[^/]*(?=@)", ""); @@ -995,6 +1042,55 @@ private void ParseOption(string name, string value) case "minpoolsize": _minPoolSize = ParseInt32(name, value); break; + case "proxyhost": + if (!string.IsNullOrEmpty(_proxyHost)) + { + throw new MongoConfigurationException("Multiple proxyHost options are not allowed."); + } + + _proxyHost = value; + if (_proxyHost.Length == 0) + { + throw new MongoConfigurationException("proxyHost cannot be empty."); + } + break; + case "proxyport": + if (_proxyPort != null) + { + throw new MongoConfigurationException("Multiple proxyPort options are not allowed."); + } + + var proxyPortValue = ParseInt32(name, value); + if (proxyPortValue is < 0 or > 65535) + { + throw new MongoConfigurationException("proxyPort must be between 0 and 65535."); + } + _proxyPort = proxyPortValue; + break; + case "proxyusername": + if (!string.IsNullOrEmpty(_proxyUsername)) + { + throw new MongoConfigurationException("Multiple proxyUsername options are not allowed."); + } + + _proxyUsername = value; + if (_proxyUsername.Length == 0) + { + throw new MongoConfigurationException("proxyUsername cannot be empty."); + } + break; + case "proxypassword": + if (!string.IsNullOrEmpty(_proxyPassword)) + { + throw new MongoConfigurationException("Multiple proxyPassword options are not allowed."); + } + + _proxyPassword = value; + if (_proxyPassword.Length == 0) + { + throw new MongoConfigurationException("proxyPassword cannot be empty."); + } + break; case "readconcernlevel": _readConcernLevel = ParseEnum(name, value); break; diff --git a/src/MongoDB.Driver/Core/Configuration/TcpStreamSettings.cs b/src/MongoDB.Driver/Core/Configuration/TcpStreamSettings.cs index 5a6550fa592..1ba8f38a3c4 100644 --- a/src/MongoDB.Driver/Core/Configuration/TcpStreamSettings.cs +++ b/src/MongoDB.Driver/Core/Configuration/TcpStreamSettings.cs @@ -16,6 +16,7 @@ using System; using System.Net.Sockets; using System.Threading; +using MongoDB.Driver.Core.Connections; using MongoDB.Driver.Core.Misc; namespace MongoDB.Driver.Core.Configuration @@ -32,6 +33,7 @@ public class TcpStreamSettings private readonly int _receiveBufferSize; private readonly int _sendBufferSize; private readonly Action _socketConfigurator; + private readonly Socks5ProxySettings _socks5ProxySettings; private readonly TimeSpan? _writeTimeout; // constructors @@ -44,6 +46,7 @@ public class TcpStreamSettings /// Size of the receive buffer. /// Size of the send buffer. /// The socket configurator. + /// The SOCKS5 proxy settings. /// The write timeout. public TcpStreamSettings( Optional addressFamily = default(Optional), @@ -52,7 +55,8 @@ public TcpStreamSettings( Optional receiveBufferSize = default(Optional), Optional sendBufferSize = default(Optional), Optional> socketConfigurator = default(Optional>), - Optional writeTimeout = default(Optional)) + Optional writeTimeout = default(Optional), + Optional socks5ProxySettings = default(Optional)) { _addressFamily = addressFamily.WithDefault(AddressFamily.InterNetwork); _connectTimeout = Ensure.IsInfiniteOrGreaterThanOrEqualToZero(connectTimeout.WithDefault(Timeout.InfiniteTimeSpan), "connectTimeout"); @@ -61,8 +65,31 @@ public TcpStreamSettings( _sendBufferSize = Ensure.IsGreaterThanZero(sendBufferSize.WithDefault(64 * 1024), "sendBufferSize"); _socketConfigurator = socketConfigurator.WithDefault(null); _writeTimeout = Ensure.IsNullOrInfiniteOrGreaterThanOrEqualToZero(writeTimeout.WithDefault(null), "writeTimeout"); + _socks5ProxySettings = socks5ProxySettings.WithDefault(null); } + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // public TcpStreamSettings( + // Optional addressFamily, + // Optional connectTimeout, + // Optional readTimeout, + // Optional receiveBufferSize, + // Optional sendBufferSize, + // Optional> socketConfigurator, + // Optional writeTimeout) + // { + // + // } + internal TcpStreamSettings(TcpStreamSettings other) { _addressFamily = other.AddressFamily; @@ -71,6 +98,7 @@ internal TcpStreamSettings(TcpStreamSettings other) _receiveBufferSize = other.ReceiveBufferSize; _sendBufferSize = other.SendBufferSize; _socketConfigurator = other.SocketConfigurator; + _socks5ProxySettings = other._socks5ProxySettings; _writeTimeout = other.WriteTimeout; } @@ -141,6 +169,11 @@ public Action SocketConfigurator get { return _socketConfigurator; } } + /// + /// Gets the SOCKS5 proxy settings. + /// + public Socks5ProxySettings Socks5ProxySettings => _socks5ProxySettings; + /// /// Gets the write timeout. /// @@ -162,6 +195,7 @@ public TimeSpan? WriteTimeout /// Size of the receive buffer. /// Size of the send buffer. /// The socket configurator. + /// The SOCKS5 proxy settings. /// The write timeout. /// A new TcpStreamSettings instance. public TcpStreamSettings With( @@ -171,7 +205,8 @@ public TcpStreamSettings With( Optional receiveBufferSize = default(Optional), Optional sendBufferSize = default(Optional), Optional> socketConfigurator = default(Optional>), - Optional writeTimeout = default(Optional)) + Optional writeTimeout = default(Optional), + Optional socks5ProxySettings = default(Optional)) { return new TcpStreamSettings( addressFamily: addressFamily.WithDefault(_addressFamily), @@ -180,6 +215,7 @@ public TcpStreamSettings With( receiveBufferSize: receiveBufferSize.WithDefault(_receiveBufferSize), sendBufferSize: sendBufferSize.WithDefault(_sendBufferSize), socketConfigurator: socketConfigurator.WithDefault(_socketConfigurator), + socks5ProxySettings: socks5ProxySettings.WithDefault(_socks5ProxySettings), writeTimeout: writeTimeout.WithDefault(_writeTimeout)); } } diff --git a/src/MongoDB.Driver/Core/Connections/Socks5AuthenticationSettings.cs b/src/MongoDB.Driver/Core/Connections/Socks5AuthenticationSettings.cs new file mode 100644 index 00000000000..8b7bbc6dbd4 --- /dev/null +++ b/src/MongoDB.Driver/Core/Connections/Socks5AuthenticationSettings.cs @@ -0,0 +1,99 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using MongoDB.Driver.Core.Misc; +using MongoDB.Shared; + +namespace MongoDB.Driver.Core.Connections; + +/// +/// Represents the settings for SOCKS5 authentication. +/// +public abstract class Socks5AuthenticationSettings +{ + /// + /// Creates authentication settings that do not require any authentication. + /// + public static Socks5AuthenticationSettings None => new NoAuthenticationSettings(); + + /// + /// Creates authentication settings for username and password. + /// + /// The username + /// The password + /// + public static Socks5AuthenticationSettings UsernamePassword(string username, string password) + => new UsernamePasswordAuthenticationSettings(username, password); + + /// + /// Represents settings for no authentication in SOCKS5. + /// + public sealed class NoAuthenticationSettings : Socks5AuthenticationSettings + { + /// + public override bool Equals(object obj) + { + return obj is Socks5AuthenticationSettings; + } + + /// + public override int GetHashCode() + { + return 1; + } + } + + /// + /// Represents settings for username and password authentication in SOCKS5. + /// + public sealed class UsernamePasswordAuthenticationSettings : Socks5AuthenticationSettings + { + /// + /// Gets the username for authentication. + /// + public string Username { get; } + + /// + /// Gets the password for authentication. + /// + public string Password { get; } + + internal UsernamePasswordAuthenticationSettings(string username, string password) + { + Username = Ensure.IsNotNullOrEmpty(username, nameof(username)); + Password = Ensure.IsNotNullOrEmpty(password, nameof(password)); + } + + /// + public override bool Equals(object obj) + { + if (obj is UsernamePasswordAuthenticationSettings other) + { + return Username == other.Username && Password == other.Password; + } + + return false; + } + + /// + public override int GetHashCode() + { + return new Hasher() + .Hash(Username) + .Hash(Password) + .GetHashCode(); + } + } +} \ No newline at end of file diff --git a/src/MongoDB.Driver/Core/Connections/Socks5Helper.cs b/src/MongoDB.Driver/Core/Connections/Socks5Helper.cs new file mode 100644 index 00000000000..33ca004957e --- /dev/null +++ b/src/MongoDB.Driver/Core/Connections/Socks5Helper.cs @@ -0,0 +1,316 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.GridFS; + +namespace MongoDB.Driver.Core.Connections; + +internal static class Socks5Helper +{ + // Schemas for requests/responses are taken from the following RFCs: + // SOCKS Protocol Version 5 - https://datatracker.ietf.org/doc/html/rfc1928 + // Username/Password Authentication for SOCKS V5 - https://datatracker.ietf.org/doc/html/rfc1929 + + // Greeting request + // +----+----------+----------+ + // |VER | NMETHODS | METHODS | + // +----+----------+----------+ + // | 1 | 1 | 1 to 255 | + // +----+----------+----------+ + + // Greeting response + // +----+--------+ + // |VER | METHOD | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + + // Authentication request -- if using username/password authentication + // +----+------+----------+------+----------+ + // |VER | ULEN | UNAME | PLEN | PASSWD | + // +----+------+----------+------+----------+ + // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + // +----+------+----------+------+----------+ + + // Authentication response + // +----+--------+ + // |VER | STATUS | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + + // Connect request + // +----+-----+-------+------+----------+----------+ + // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + + // Connect response + // +----+-----+-------+------+----------+----------+ + // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + + //General use constants + private const byte ProtocolVersion5 = 0x05; + private const byte Socks5Success = 0x00; + private const byte Reserved = 0x00; + private const byte CmdConnect = 0x01; + + //Auth constants + private const byte MethodNoAuth = 0x00; + private const byte MethodUsernamePassword = 0x02; + private const byte SubnegotiationVersion = 0x01; + + //Address type constants + private const byte AddressTypeIPv4 = 0x01; + private const byte AddressTypeIPv6 = 0x04; + private const byte AddressTypeDomain = 0x03; + + // Largest possible message size when using username and password auth. + private const int BufferSize = 513; + + public static void PerformSocks5Handshake(Stream stream, EndPoint endPoint, Socks5AuthenticationSettings authenticationSettings, CancellationToken cancellationToken) + { + var (targetHost, targetPort) = endPoint.GetHostAndPort(); + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + var useAuth = authenticationSettings is Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings; + + var greetingRequestLength = CreateGreetingRequest(buffer, useAuth); + stream.Write(buffer, 0, greetingRequestLength); + + stream.ReadBytes(buffer, 0, 2, cancellationToken); + var acceptsUsernamePasswordAuth = ProcessGreetingResponse(buffer, useAuth); + + // If we have username and password, but the proxy doesn't need them, we skip. + if (useAuth && acceptsUsernamePasswordAuth) + { + var authenticationRequestLength = CreateAuthenticationRequest(buffer, authenticationSettings); + stream.Write(buffer, 0, authenticationRequestLength); + + stream.ReadBytes(buffer, 0, 2, cancellationToken); + ProcessAuthenticationResponse(buffer); + } + + var connectRequestLength = CreateConnectRequest(buffer, targetHost, targetPort); + stream.Write(buffer, 0, connectRequestLength); + + stream.ReadBytes(buffer, 0, 5, cancellationToken); + var skip = ProcessConnectResponse(buffer); + stream.ReadBytes(buffer, 0, skip, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static async Task PerformSocks5HandshakeAsync(Stream stream, EndPoint endPoint, Socks5AuthenticationSettings authenticationSettings, CancellationToken cancellationToken) + { + var (targetHost, targetPort) = endPoint.GetHostAndPort(); + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + var useAuth = authenticationSettings is Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings; + + var greetingRequestLength = CreateGreetingRequest(buffer, useAuth); + await stream.WriteAsync(buffer, 0, greetingRequestLength, cancellationToken).ConfigureAwait(false); + + await stream.ReadBytesAsync(buffer, 0, 2, cancellationToken).ConfigureAwait(false); + var acceptsUsernamePasswordAuth = ProcessGreetingResponse(buffer, useAuth); + + // If we have username and password, but the proxy doesn't need them, we skip. + if (useAuth && acceptsUsernamePasswordAuth) + { + var authenticationRequestLength = CreateAuthenticationRequest(buffer, authenticationSettings); + await stream.WriteAsync(buffer, 0, authenticationRequestLength, cancellationToken).ConfigureAwait(false); + + await stream.ReadBytesAsync(buffer, 0, 2, cancellationToken).ConfigureAwait(false); + ProcessAuthenticationResponse(buffer); + } + + var connectRequestLength = CreateConnectRequest(buffer, targetHost, targetPort); + await stream.WriteAsync(buffer, 0, connectRequestLength, cancellationToken).ConfigureAwait(false); + + await stream.ReadBytesAsync(buffer, 0, 5, cancellationToken).ConfigureAwait(false); + var skip = ProcessConnectResponse(buffer); + await stream.ReadBytesAsync(buffer, 0, skip, cancellationToken).ConfigureAwait(true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static int CreateGreetingRequest(byte[] buffer, bool useAuth) + { + buffer[0] = ProtocolVersion5; + + //buffer[1] is the number of methods supported by the client. + if (!useAuth) + { + buffer[1] = 1; + buffer[2] = MethodNoAuth; + return 3; + } + + buffer[1] = 2; + buffer[2] = MethodNoAuth; + buffer[3] = MethodUsernamePassword; + return 4; + } + + private static bool ProcessGreetingResponse(byte[] buffer, bool useAuth) + { + VerifyProtocolVersion(buffer[0]); + var acceptedMethod = buffer[1]; + if (acceptedMethod == MethodUsernamePassword) + { + if (!useAuth) + { + throw new IOException("SOCKS5 proxy requires authentication, but no credentials were provided."); + } + + return true; + } + + if (acceptedMethod != MethodNoAuth) + { + throw new IOException("SOCKS5 proxy requires unsupported authentication method."); + } + + return false; + } + + private static int CreateAuthenticationRequest(byte[] buffer, Socks5AuthenticationSettings authenticationSettings) + { + var usernamePasswordAuthenticationSettings = (Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings)authenticationSettings; + var proxyUsername = usernamePasswordAuthenticationSettings.Username; + var proxyPassword = usernamePasswordAuthenticationSettings.Password; + + // We need to add version, username.length, username, password.length, password (in this order) + buffer[0] = SubnegotiationVersion; + var usernameLength = EncodeString(proxyUsername, buffer, 2, nameof(proxyUsername)); + buffer[1] = usernameLength; + var passwordLength = EncodeString(proxyPassword, buffer, 3 + usernameLength, nameof(proxyPassword)); + buffer[2 + usernameLength] = passwordLength; + + return 3 + usernameLength + passwordLength; + } + + private static void ProcessAuthenticationResponse(byte[] buffer) + { + if (buffer[0] != SubnegotiationVersion || buffer[1] != Socks5Success) + { + throw new IOException("SOCKS5 authentication failed."); + } + } + + private static int CreateConnectRequest(byte[] buffer, string targetHost, int targetPort) + { + buffer[0] = ProtocolVersion5; + buffer[1] = CmdConnect; + buffer[2] = Reserved; + int addressLength; + + if (IPAddress.TryParse(targetHost, out var ip)) + { + switch (ip.AddressFamily) + { + case AddressFamily.InterNetwork: + buffer[3] = AddressTypeIPv4; + Array.Copy(ip.GetAddressBytes(), 0, buffer, 4, 4); + addressLength = 4; + break; + case AddressFamily.InterNetworkV6: + buffer[3] = AddressTypeIPv6; + Array.Copy(ip.GetAddressBytes(), 0, buffer, 4, 16); + addressLength = 16; + break; + default: + throw new IOException("Invalid target host address family."); + } + } + else + { + buffer[3] = AddressTypeDomain; + var hostLength = EncodeString(targetHost, buffer, 5, nameof(targetHost)); + buffer[4] = hostLength; + addressLength = hostLength + 1; + } + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)targetPort); + + return addressLength + 6; + } + + // Reads the SOCKS5 connect response and returns the number of bytes to skip in the buffer. + private static int ProcessConnectResponse(byte[] buffer) + { + VerifyProtocolVersion(buffer[0]); + + if (buffer[1] != Socks5Success) + { + throw new IOException($"SOCKS5 connect failed"); //TODO Need to add the reason here. + } + + // We skip the last bytes of the response as we do not need them. + // We skip length(dst.address) + length(dst.port) - 1 --- length(dst.port) is always 2 + // -1 because we already ready the first byte of the address type + // (used for the variable length domain-type addresses) + return buffer[3] switch + { + AddressTypeIPv4 => 5, + AddressTypeIPv6 => 17, + AddressTypeDomain => buffer[4] + 2, + _ => throw new IOException("Unknown address type in SOCKS5 reply.") + }; + } + + private static void VerifyProtocolVersion(byte version) + { + if (version != ProtocolVersion5) + { + throw new IOException("Invalid SOCKS version in response."); + } + } + + private static byte EncodeString(string input, byte[] buffer, int offset, string parameterName) + { + try + { + var written = Encoding.UTF8.GetBytes(input, 0, input.Length, buffer, offset); + return checked((byte)written); + } + catch + { + throw new IOException($"The {parameterName} could not be encoded as UTF-8."); + } + } +} \ No newline at end of file diff --git a/src/MongoDB.Driver/Core/Connections/Socks5ProxySettings.cs b/src/MongoDB.Driver/Core/Connections/Socks5ProxySettings.cs new file mode 100644 index 00000000000..21291159670 --- /dev/null +++ b/src/MongoDB.Driver/Core/Connections/Socks5ProxySettings.cs @@ -0,0 +1,103 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Text; +using MongoDB.Driver.Core.Misc; +using MongoDB.Shared; + +namespace MongoDB.Driver.Core.Connections; + +/// +/// Represents the settings for a SOCKS5 proxy connection. +/// +public sealed class Socks5ProxySettings +{ + private const int DefaultPort = 1080; + + /// + /// Gets the host of the SOCKS5 proxy. + /// + public string Host { get; } + + /// + /// Gets the port of the SOCKS5 proxy. + /// + public int Port { get; } + + /// + /// Gets the authentication settings of the SOCKS5 proxy. + /// + public Socks5AuthenticationSettings Authentication { get; } + + internal Socks5ProxySettings(string host, int? port, Socks5AuthenticationSettings authentication) + { + Host = Ensure.IsNotNullOrEmpty(host, nameof(host)); + Port = port is null ? DefaultPort : Ensure.IsBetween(port.Value, 1, 65535, nameof(port)); + Authentication = authentication ?? Socks5AuthenticationSettings.None; + } + + // Convenience method used internally. + internal static Socks5ProxySettings Create(string host, int? port, string username, string password) + { + var authentication = !string.IsNullOrEmpty(username) ? + Socks5AuthenticationSettings.UsernamePassword(username, password) : Socks5AuthenticationSettings.None; + + return new Socks5ProxySettings(host, port, authentication); + } + + /// + public override bool Equals(object obj) + { + if (obj is Socks5ProxySettings other) + { + return Host == other.Host && + Port == other.Port && + Equals(Authentication, other.Authentication); + } + + return false; + } + + /// + public override int GetHashCode() + { + return new Hasher() + .Hash(Host) + .Hash(Port) + .Hash(Authentication) + .GetHashCode(); + } + + /// + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append("{ Host : "); + sb.Append(Host); + sb.Append(", Port : "); + sb.Append(Port); + sb.Append(", Authentication : "); + + sb.Append(Authentication switch + { + Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings up => + $"UsernamePassword (Username: {up.Username}, Password: {up.Password})", + _ => "None" + }); + + sb.Append(" }"); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/MongoDB.Driver/Core/Connections/Socks5ProxySettingsBuilder.cs b/src/MongoDB.Driver/Core/Connections/Socks5ProxySettingsBuilder.cs new file mode 100644 index 00000000000..df4f3e84dfc --- /dev/null +++ b/src/MongoDB.Driver/Core/Connections/Socks5ProxySettingsBuilder.cs @@ -0,0 +1,66 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace MongoDB.Driver.Core.Connections; + +/// +/// Builder for creating . +/// +public class Socks5ProxySettingsBuilder +{ + private readonly string _host; + private int? _port; + private Socks5AuthenticationSettings _authentication; + + /// + /// Initializes a new instance of the class with the specified host. + /// + /// The host of the SOCKS5 proxy. + public Socks5ProxySettingsBuilder(string host) + { + _host = host; + } + + /// + /// Sets the port for the SOCKS5 proxy. + /// + /// The port of the SOCKS5 proxy. + /// The builder instance for method chaining. + public Socks5ProxySettingsBuilder Port(int port) + { + _port = port; + return this; + } + + /// + /// Sets the authentication for the SOCKS5 proxy using username and password. + /// + /// The username for authentication. + /// The password for authentication. + /// The builder instance for method chaining. + public Socks5ProxySettingsBuilder UsernameAndPasswordAuth(string username, string password) + { + _authentication = Socks5AuthenticationSettings.UsernamePassword(username, password); + return this; + } + + /// + /// Builds the instance with the specified settings. + /// + public Socks5ProxySettings Build() + { + return new Socks5ProxySettings(_host, _port, _authentication); + } +} \ No newline at end of file diff --git a/src/MongoDB.Driver/Core/Connections/TcpStreamFactory.cs b/src/MongoDB.Driver/Core/Connections/TcpStreamFactory.cs index 9bb93eea097..344e3b2e51e 100644 --- a/src/MongoDB.Driver/Core/Connections/TcpStreamFactory.cs +++ b/src/MongoDB.Driver/Core/Connections/TcpStreamFactory.cs @@ -48,19 +48,36 @@ public TcpStreamFactory(TcpStreamSettings settings) // methods public Stream CreateStream(EndPoint endPoint, CancellationToken cancellationToken) { + var socks5ProxySettings = _settings.Socks5ProxySettings; + var useProxy = socks5ProxySettings != null; + var targetEndpoint = socks5ProxySettings != null ? new DnsEndPoint(socks5ProxySettings.Host, socks5ProxySettings.Port) : endPoint; + #if NET472 - var socket = CreateSocket(endPoint); - Connect(socket, endPoint, cancellationToken); - return CreateNetworkStream(socket); + var socket = CreateSocket(targetEndpoint); + Connect(socket, targetEndpoint, cancellationToken); + var stream = CreateNetworkStream(socket); + if (useProxy) + { + Socks5Helper.PerformSocks5Handshake(stream, endPoint, socks5ProxySettings.Authentication, cancellationToken); + } + + return stream; #else - var resolved = ResolveEndPoints(endPoint); + var resolved = ResolveEndPoints(targetEndpoint); for (int i = 0; i < resolved.Length; i++) { try { var socket = CreateSocket(resolved[i]); Connect(socket, resolved[i], cancellationToken); - return CreateNetworkStream(socket); + var stream = CreateNetworkStream(socket); + + if (useProxy) + { + Socks5Helper.PerformSocks5Handshake(stream, endPoint, socks5ProxySettings.Authentication, cancellationToken); + } + + return stream; } catch { @@ -74,25 +91,42 @@ public Stream CreateStream(EndPoint endPoint, CancellationToken cancellationToke } // we should never get here... - throw new InvalidOperationException("Unabled to resolve endpoint."); + throw new InvalidOperationException("Unable to resolve endpoint."); #endif } public async Task CreateStreamAsync(EndPoint endPoint, CancellationToken cancellationToken) { + var socks5ProxySettings = _settings.Socks5ProxySettings; + var useProxy = socks5ProxySettings != null; + var targetEndpoint = socks5ProxySettings != null ? new DnsEndPoint(socks5ProxySettings.Host, socks5ProxySettings.Port) : endPoint; + #if NET472 - var socket = CreateSocket(endPoint); - await ConnectAsync(socket, endPoint, cancellationToken).ConfigureAwait(false); - return CreateNetworkStream(socket); + var socket = CreateSocket(targetEndpoint); + await ConnectAsync(socket, targetEndpoint, cancellationToken).ConfigureAwait(false); + var stream = CreateNetworkStream(socket); + if (useProxy) + { + await Socks5Helper.PerformSocks5HandshakeAsync(stream, endPoint, socks5ProxySettings.Authentication, cancellationToken).ConfigureAwait(false); + } + + return stream; #else - var resolved = await ResolveEndPointsAsync(endPoint).ConfigureAwait(false); + var resolved = await ResolveEndPointsAsync(targetEndpoint).ConfigureAwait(false); for (int i = 0; i < resolved.Length; i++) { try { var socket = CreateSocket(resolved[i]); await ConnectAsync(socket, resolved[i], cancellationToken).ConfigureAwait(false); - return CreateNetworkStream(socket); + var stream = CreateNetworkStream(socket); + + if (useProxy) + { + await Socks5Helper.PerformSocks5HandshakeAsync(stream, endPoint, socks5ProxySettings.Authentication, cancellationToken).ConfigureAwait(false); + } + + return stream; } catch { @@ -258,20 +292,18 @@ private Socket CreateSocket(EndPoint endPoint) private EndPoint[] ResolveEndPoints(EndPoint initial) { - var dnsInitial = initial as DnsEndPoint; - if (dnsInitial == null) + if (initial is not DnsEndPoint dnsInitial) { - return new[] { initial }; + return [initial]; } - IPAddress address; - if (IPAddress.TryParse(dnsInitial.Host, out address)) + if (IPAddress.TryParse(dnsInitial.Host, out var address)) { - return new[] { new IPEndPoint(address, dnsInitial.Port) }; + return [new IPEndPoint(address, dnsInitial.Port)]; } var preferred = initial.AddressFamily; - if (preferred == AddressFamily.Unspecified || preferred == AddressFamily.Unknown) + if (preferred is AddressFamily.Unspecified or AddressFamily.Unknown) { preferred = _settings.AddressFamily; } @@ -285,20 +317,18 @@ private EndPoint[] ResolveEndPoints(EndPoint initial) private async Task ResolveEndPointsAsync(EndPoint initial) { - var dnsInitial = initial as DnsEndPoint; - if (dnsInitial == null) + if (initial is not DnsEndPoint dnsInitial) { - return new[] { initial }; + return [initial]; } - IPAddress address; - if (IPAddress.TryParse(dnsInitial.Host, out address)) + if (IPAddress.TryParse(dnsInitial.Host, out var address)) { - return new[] { new IPEndPoint(address, dnsInitial.Port) }; + return [new IPEndPoint(address, dnsInitial.Port)]; } var preferred = initial.AddressFamily; - if (preferred == AddressFamily.Unspecified || preferred == AddressFamily.Unknown) + if (preferred is AddressFamily.Unspecified or AddressFamily.Unknown) { preferred = _settings.AddressFamily; } diff --git a/src/MongoDB.Driver/MongoClientSettings.cs b/src/MongoDB.Driver/MongoClientSettings.cs index d7da362ca92..40c877b812b 100644 --- a/src/MongoDB.Driver/MongoClientSettings.cs +++ b/src/MongoDB.Driver/MongoClientSettings.cs @@ -20,9 +20,9 @@ using System.Text; using MongoDB.Driver.Core.Compression; using MongoDB.Driver.Core.Configuration; +using MongoDB.Driver.Core.Connections; using MongoDB.Driver.Core.Misc; using MongoDB.Driver.Core.Servers; -using MongoDB.Driver.Encryption; using MongoDB.Shared; namespace MongoDB.Driver @@ -71,6 +71,7 @@ public class MongoClientSettings : IEquatable, IInheritable private ServerMonitoringMode _serverMonitoringMode; private TimeSpan _serverSelectionTimeout; private TimeSpan _socketTimeout; + private Socks5ProxySettings _socks5ProxySettings; private int _srvMaxHosts; private string _srvServiceName; private SslSettings _sslSettings; @@ -122,6 +123,7 @@ public MongoClientSettings() _serverMonitoringMode = ServerMonitoringMode.Auto; _serverSelectionTimeout = MongoDefaults.ServerSelectionTimeout; _socketTimeout = MongoDefaults.SocketTimeout; + _socks5ProxySettings = null; _srvMaxHosts = 0; _srvServiceName = MongoInternalDefaults.MongoClientSettings.SrvServiceName; _sslSettings = null; @@ -428,6 +430,19 @@ public int MinConnectionPoolSize } } + /// + /// Gets or sets the SOCKS5 proxy settings. + /// + public Socks5ProxySettings Socks5ProxySettings + { + get => _socks5ProxySettings; + set + { + if (_isFrozen) { throw new InvalidOperationException("MongoClientSettings is frozen."); } + _socks5ProxySettings = value; + } + } + /// /// Gets or sets the read concern. /// @@ -874,6 +889,10 @@ public static MongoClientSettings FromUrl(MongoUrl url) clientSettings.ServerMonitoringMode = url.ServerMonitoringMode ?? ServerMonitoringMode.Auto; clientSettings.ServerSelectionTimeout = url.ServerSelectionTimeout; clientSettings.SocketTimeout = url.SocketTimeout; + if (!string.IsNullOrEmpty(url.ProxyHost)) + { + clientSettings.Socks5ProxySettings = Socks5ProxySettings.Create(url.ProxyHost, url.ProxyPort, url.ProxyUsername, url.ProxyPassword); + } clientSettings.SrvMaxHosts = url.SrvMaxHosts.GetValueOrDefault(0); clientSettings.SrvServiceName = url.SrvServiceName; clientSettings.SslSettings = null; @@ -932,6 +951,7 @@ public MongoClientSettings Clone() clone._serverMonitoringMode = _serverMonitoringMode; clone._serverSelectionTimeout = _serverSelectionTimeout; clone._socketTimeout = _socketTimeout; + clone._socks5ProxySettings = _socks5ProxySettings; clone._srvMaxHosts = _srvMaxHosts; clone._srvServiceName = _srvServiceName; clone._sslSettings = (_sslSettings == null) ? null : _sslSettings.Clone(); @@ -1001,6 +1021,7 @@ public override bool Equals(object obj) _serverMonitoringMode == rhs._serverMonitoringMode && _serverSelectionTimeout == rhs._serverSelectionTimeout && _socketTimeout == rhs._socketTimeout && + object.Equals(_socks5ProxySettings, rhs._socks5ProxySettings) && _srvMaxHosts == rhs._srvMaxHosts && _srvServiceName == rhs._srvServiceName && _sslSettings == rhs._sslSettings && @@ -1088,6 +1109,7 @@ public override int GetHashCode() .Hash(_serverMonitoringMode) .Hash(_serverSelectionTimeout) .Hash(_socketTimeout) + .Hash(_socks5ProxySettings) .Hash(_srvMaxHosts) .Hash(_srvServiceName) .Hash(_sslSettings) @@ -1145,6 +1167,10 @@ public override string ToString() sb.AppendFormat("MaxConnectionLifeTime={0};", _maxConnectionLifeTime); sb.AppendFormat("MaxConnectionPoolSize={0};", _maxConnectionPoolSize); sb.AppendFormat("MinConnectionPoolSize={0};", _minConnectionPoolSize); + if (_socks5ProxySettings!= null) + { + sb.AppendFormat("ProxyHost={0};", _socks5ProxySettings); + } if (_readEncoding != null) { sb.Append("ReadEncoding=UTF8Encoding;"); @@ -1221,6 +1247,7 @@ internal ClusterKey ToClusterKey() _serverMonitoringMode, _serverSelectionTimeout, _socketTimeout, + _socks5ProxySettings, _srvMaxHosts, _srvServiceName, _sslSettings, diff --git a/src/MongoDB.Driver/MongoUrl.cs b/src/MongoDB.Driver/MongoUrl.cs index 2c1446fd57c..912c0b15c49 100644 --- a/src/MongoDB.Driver/MongoUrl.cs +++ b/src/MongoDB.Driver/MongoUrl.cs @@ -64,6 +64,10 @@ public class MongoUrl : IEquatable private readonly bool? _retryReads; private readonly bool? _retryWrites; private readonly TimeSpan _localThreshold; + private readonly string _proxyHost; + private readonly int? _proxyPort; + private readonly string _proxyUsername; + private readonly string _proxyPassword; private readonly ConnectionStringScheme _scheme; private readonly IEnumerable _servers; private readonly ServerMonitoringMode? _serverMonitoringMode; @@ -117,6 +121,10 @@ internal MongoUrl(MongoUrlBuilder builder) _maxConnectionPoolSize = builder.MaxConnectionPoolSize; _minConnectionPoolSize = builder.MinConnectionPoolSize; _password = builder.Password; + _proxyHost = builder.ProxyHost; + _proxyPort = builder.ProxyPort; + _proxyUsername = builder.ProxyUsername; + _proxyPassword = builder.ProxyPassword; _readConcernLevel = builder.ReadConcernLevel; _readPreference = builder.ReadPreference; _replicaSetName = builder.ReplicaSetName; @@ -358,6 +366,26 @@ public string Password get { return _password; } } + /// + /// Gets the proxy host. + /// + public string ProxyHost => _proxyHost; + + /// + /// Gets the proxy port. + /// + public int? ProxyPort => _proxyPort; + + /// + /// Gets the proxy username. + /// + public string ProxyUsername => _proxyUsername; + + /// + /// Gets the proxy password. + /// + public string ProxyPassword => _proxyPassword; + /// /// Gets the read concern level. /// diff --git a/src/MongoDB.Driver/MongoUrlBuilder.cs b/src/MongoDB.Driver/MongoUrlBuilder.cs index 3858228cbf6..c50288823c2 100644 --- a/src/MongoDB.Driver/MongoUrlBuilder.cs +++ b/src/MongoDB.Driver/MongoUrlBuilder.cs @@ -60,6 +60,10 @@ public class MongoUrlBuilder private string _replicaSetName; private bool? _retryReads; private bool? _retryWrites; + private string _proxyHost; + private int? _proxyPort; + private string _proxyUsername; + private string _proxyPassword; private ConnectionStringScheme _scheme; private IEnumerable _servers; private ServerMonitoringMode? _serverMonitoringMode; @@ -104,6 +108,10 @@ public MongoUrlBuilder() _maxConnectionPoolSize = MongoDefaults.MaxConnectionPoolSize; _minConnectionPoolSize = MongoDefaults.MinConnectionPoolSize; _password = null; + _proxyHost = null; + _proxyPort = null; + _proxyUsername = null; + _proxyPassword = null; _readConcernLevel = null; _readPreference = null; _replicaSetName = null; @@ -438,6 +446,59 @@ public string Password set { _password = value; } } + /// + /// + /// + public string ProxyHost + { + get => _proxyHost; + set + { + _proxyHost = Ensure.IsNotNullOrEmpty(value, nameof(ProxyHost)); + } + } + + /// + /// + /// + /// + public int? ProxyPort + { + get => _proxyPort; + set + { + if (value is < 0 or > 65535) + { + throw new ArgumentOutOfRangeException(nameof(value), "ProxyPort must be between 0 and 65535."); + } + _proxyPort = value; + } + } + + /// + /// + /// + public string ProxyUsername + { + get => _proxyUsername; + set + { + _proxyUsername = Ensure.IsNotNullOrEmpty(value, nameof(ProxyUsername)); + } + } + + /// + /// + /// + public string ProxyPassword + { + get => _proxyPassword; + set + { + _proxyPassword = Ensure.IsNotNullOrEmpty(value, nameof(ProxyPassword)); + } + } + /// /// Gets or sets the read concern level. /// @@ -760,6 +821,7 @@ public MongoUrl ToMongoUrl() /// The canonical URL. public override string ToString() { + //TODO Need to add options here too StringBuilder url = new StringBuilder(); if (_scheme == ConnectionStringScheme.MongoDB) { @@ -980,6 +1042,22 @@ public override string ToString() { query.AppendFormat("retryWrites={0}&", JsonConvert.ToString(_retryWrites.Value)); } + if(!string.IsNullOrEmpty(_proxyHost)) + { + query.AppendFormat("proxyHost={0}&", _proxyHost); + } + if (_proxyPort.HasValue) + { + query.AppendFormat("proxyPort={0}&", _proxyPort); + } + if (!string.IsNullOrEmpty(_proxyUsername)) + { + query.AppendFormat("proxyUsername={0}&", _proxyUsername); + } + if (!string.IsNullOrEmpty(_proxyPassword)) + { + query.AppendFormat("proxyPassword={0}&", _proxyPassword); + } if (_srvMaxHosts.HasValue) { query.AppendFormat("srvMaxHosts={0}&", _srvMaxHosts); @@ -1026,6 +1104,10 @@ private void InitializeFromConnectionString(ConnectionString connectionString) _maxConnectionPoolSize = connectionString.MaxPoolSize.GetValueOrDefault(MongoDefaults.MaxConnectionPoolSize); _minConnectionPoolSize = connectionString.MinPoolSize.GetValueOrDefault(MongoDefaults.MinConnectionPoolSize); _password = connectionString.Password; + _proxyHost = connectionString.ProxyHost; + _proxyPort = connectionString.ProxyPort; + _proxyUsername = connectionString.ProxyUsername; + _proxyPassword = connectionString.ProxyPassword; _readConcernLevel = connectionString.ReadConcernLevel; if (connectionString.ReadPreference.HasValue || connectionString.ReadPreferenceTags != null || connectionString.MaxStaleness.HasValue) { diff --git a/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs b/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs index 481da3442c7..220c35d4f8b 100644 --- a/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs +++ b/tests/MongoDB.Driver.Tests/ClusterKeyTests.cs @@ -268,6 +268,7 @@ private ClusterKey CreateSubject(string notEqualFieldName = null) serverMonitoringMode, serverSelectionTimeout, socketTimeout, + null, //TODO Add correct proxy for tests srvMaxHosts, srvServiceName, sslSettings, @@ -353,6 +354,7 @@ internal ClusterKey CreateSubjectWith( serverMonitoringMode, serverSelectionTimeout, socketTimeout, + null, //TODO Add correct proxy for tests srvMaxHosts, srvServiceName, sslSettings, diff --git a/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs b/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs index ded3872bc19..2de66202c09 100644 --- a/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs +++ b/tests/MongoDB.Driver.Tests/Core/Configuration/ConnectionStringTests.cs @@ -374,6 +374,10 @@ public void When_nothing_is_specified(string connectionString) subject.MaxPoolSize.Should().Be(null); subject.MinPoolSize.Should().Be(null); subject.Password.Should().BeNull(); + subject.ProxyHost.Should().Be(null); + subject.ProxyPort.Should().Be(null); + subject.ProxyPassword.Should().Be(null); + subject.ProxyUsername.Should().Be(null); subject.ReadConcernLevel.Should().BeNull(); subject.ReadPreference.Should().BeNull(); subject.ReadPreferenceTags.Should().BeNull(); @@ -420,6 +424,10 @@ public void When_everything_is_specified() "maxLifeTime=5ms;" + "maxPoolSize=20;" + "minPoolSize=15;" + + "proxyHost=host.com;" + + "proxyPort=2020;" + + "proxyUsername=user;" + + "proxyPassword=passw;" + "readConcernLevel=majority;" + "readPreference=primary;" + "readPreferenceTags=dc:1;" + @@ -462,6 +470,10 @@ public void When_everything_is_specified() subject.MaxPoolSize.Should().Be(20); subject.MinPoolSize.Should().Be(15); subject.Password.Should().Be("pass"); + subject.ProxyHost.Should().Be("host.com"); + subject.ProxyPort.Should().Be(2020); + subject.ProxyUsername.Should().Be("user"); + subject.ProxyPassword.Should().Be("passw"); subject.ReadConcernLevel.Should().Be(ReadConcernLevel.Majority); subject.ReadPreference.Should().Be(ReadPreferenceMode.Primary); subject.ReadPreferenceTags.Single().Should().Be(new TagSet(new[] { new Tag("dc", "1") })); @@ -1200,6 +1212,46 @@ public void When_srvServiceName_is_specified_without_a_srv_scheme() exception.Message.Should().Contain("srvServiceName"); } + [Theory] + [InlineData("mongodb://localhost?proxyHost=222.222.222.12", "222.222.222.12", null, null, null)] + [InlineData("mongodb://localhost?proxyHost=222.222.222.12&proxyPort=8080", "222.222.222.12", 8080, null, null)] + [InlineData("mongodb://localhost?proxyHost=example.com", "example.com", null, null, null)] + [InlineData("mongodb://localhost?proxyHost=example.com&proxyPort=8080", "example.com", 8080, null, null)] + [InlineData("mongodb://localhost?proxyHost=example.com&proxyUsername=user&proxyPassword=passw", "example.com", null, "user", "passw")] + [InlineData("mongodb://localhost?proxyHost=example.com&proxyPort=8080&proxyUsername=user&proxyPassword=passw", "example.com", 8080, "user", "passw")] + public void When_proxy_parameters_are_specified(string connectionString, string host, int? port, string username, string password) + { + var subject = new ConnectionString(connectionString); + + subject.ProxyHost.Should().Be(host); + subject.ProxyPort.Should().Be(port); + subject.ProxyUsername.Should().Be(username); + subject.ProxyPassword.Should().Be(password); + } + + [Theory] + [InlineData("mongodb://localhost?proxyPort=2020", "proxyPort")] + [InlineData("mongodb://localhost?proxyUsername=user", "proxyUsername")] + [InlineData("mongodb://localhost?proxyPassword=pasw", "proxyPassword")] + public void When_proxyParameter_is_specified_without_proxyHost(string connectionString, string parameterName) + { + var exception = Record.Exception(() => new ConnectionString(connectionString)); + + exception.Should().BeOfType(); + exception.Message.Should().Contain(parameterName); + } + + [Theory] + [InlineData("mongodb://localhost?proxyHost=host.com&proxyUsername=user")] + [InlineData("mongodb://localhost?proxyHost=host.com&proxyPassword=pasw")] + public void When_proxyPassword_and_proxyUsername_are_not_specified_together(string connectionString) + { + var exception = Record.Exception(() => new ConnectionString(connectionString)); + + exception.Should().BeOfType(); + exception.Message.Should().Contain("proxyUsername and proxyPassword"); + } + [Theory] [ParameterAttributeData] public void Valid_srvMaxHosts_with_mongodbsrv_scheme_should_be_valid([Values(0, 42)]int srvMaxHosts) diff --git a/tests/MongoDB.Driver.Tests/MongoClientSettingsTests.cs b/tests/MongoDB.Driver.Tests/MongoClientSettingsTests.cs index 6c188b5c6a4..5f23fdee5d2 100644 --- a/tests/MongoDB.Driver.Tests/MongoClientSettingsTests.cs +++ b/tests/MongoDB.Driver.Tests/MongoClientSettingsTests.cs @@ -25,9 +25,9 @@ using MongoDB.Driver.Core.Clusters; using MongoDB.Driver.Core.Compression; using MongoDB.Driver.Core.Configuration; +using MongoDB.Driver.Core.Connections; using MongoDB.Driver.Core.Servers; using MongoDB.Driver.Core.TestHelpers.XunitExtensions; -using MongoDB.Driver.Encryption; using MongoDB.TestHelpers.XunitExtensions; using Moq; using Xunit; @@ -233,6 +233,8 @@ public void TestConnectTimeout() Assert.Throws(() => { settings.ConnectTimeout = connectTimeout; }); } + //TODO I understand we want to keep tests in alphabetical order, but I think it would make sense to group them by scope + //Tests like this, that should be modified for every new setting added (for instance) should be at the top of this test suite. [Fact] public void TestDefaults() { @@ -266,6 +268,7 @@ public void TestDefaults() Assert.Equal(ServerMonitoringMode.Auto, settings.ServerMonitoringMode); Assert.Equal(MongoDefaults.ServerSelectionTimeout, settings.ServerSelectionTimeout); Assert.Equal(MongoDefaults.SocketTimeout, settings.SocketTimeout); + Assert.Equal(null, settings.Socks5ProxySettings); Assert.Null(settings.SslSettings); #pragma warning disable 618 Assert.Equal(false, settings.UseSsl); @@ -435,6 +438,10 @@ public void TestEquals() clone.SocketTimeout = new TimeSpan(1, 2, 3); Assert.False(clone.Equals(settings)); + clone = settings.Clone(); + clone.Socks5ProxySettings = Socks5ProxySettings.Create("host.com", null, null, null); + Assert.False(clone.Equals(settings)); + clone = settings.Clone(); clone.SslSettings = new SslSettings { CheckCertificateRevocation = false }; Assert.False(clone.Equals(settings)); @@ -475,6 +482,7 @@ public void TestEquals() settings.ReadConcern = ReadConcern.Majority; settings.ReadEncoding = new UTF8Encoding(false, false); settings.ServerApi = new ServerApi(ServerApiVersion.V1); + settings.Socks5ProxySettings = Socks5ProxySettings.Create("host.com", 8080, null, null); settings.WriteConcern = WriteConcern.W2; settings.WriteEncoding = new UTF8Encoding(false, false); @@ -485,6 +493,7 @@ public void TestEquals() clone.ReadEncoding = new UTF8Encoding(false, false); clone.ReadPreference = clone.ReadPreference.With(settings.ReadPreference.ReadPreferenceMode); clone.ServerApi = new ServerApi(settings.ServerApi.Version); + clone.Socks5ProxySettings = Socks5ProxySettings.Create("host.com", 8080, null, null); clone.WriteConcern = WriteConcern.FromBsonDocument(settings.WriteConcern.ToBsonDocument()); clone.WriteEncoding = new UTF8Encoding(false, false); @@ -582,7 +591,8 @@ public void TestFromUrl() "maxConnecting=3;maxIdleTime=124;maxLifeTime=125;maxPoolSize=126;minPoolSize=127;readConcernLevel=majority;" + "readPreference=secondary;readPreferenceTags=a:1,b:2;readPreferenceTags=c:3,d:4;retryReads=false;retryWrites=true;socketTimeout=129;" + "serverMonitoringMode=Stream;serverSelectionTimeout=20s;tls=true;sslVerifyCertificate=false;waitqueuesize=130;waitQueueTimeout=131;" + - "w=1;fsync=true;journal=true;w=2;wtimeout=131;gssapiServiceName=other"; + "w=1;fsync=true;journal=true;w=2;wtimeout=131;gssapiServiceName=other" + + "&proxyHost=host.com&proxyPort=2020&proxyUsername=user&proxyPassword=passw"; var builder = new MongoUrlBuilder(connectionString); var url = builder.ToMongoUrl(); @@ -620,6 +630,10 @@ public void TestFromUrl() Assert.Equal(ServerMonitoringMode.Stream, settings.ServerMonitoringMode); Assert.Equal(url.ServerSelectionTimeout, settings.ServerSelectionTimeout); Assert.Equal(url.SocketTimeout, settings.SocketTimeout); + Assert.Equal(url.ProxyHost, settings.Socks5ProxySettings.Host); + Assert.Equal(url.ProxyPort, settings.Socks5ProxySettings.Port); + Assert.Equal(url.ProxyUsername, ((Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings)settings.Socks5ProxySettings.Authentication).Username); + Assert.Equal(url.ProxyPassword, ((Socks5AuthenticationSettings.UsernamePasswordAuthenticationSettings)settings.Socks5ProxySettings.Authentication).Password); #pragma warning disable 618 Assert.Equal(url.TlsDisableCertificateRevocationCheck, !settings.SslSettings.CheckCertificateRevocation); Assert.Equal(url.UseSsl, settings.UseSsl); @@ -1175,6 +1189,21 @@ public void TestSocketTimeout() Assert.Throws(() => { settings.SocketTimeout = socketTimeout; }); } + [Fact] + public void TestSocks5ProxySettings() + { + var settings = new MongoClientSettings(); + Assert.Equal(null, settings.Socks5ProxySettings); + + var newProxySettings = Socks5ProxySettings.Create("host.com", 280, "test", "test"); + settings.Socks5ProxySettings = newProxySettings; + Assert.Equal(newProxySettings, settings.Socks5ProxySettings); + + settings.Freeze(); + Assert.Equal(newProxySettings, settings.Socks5ProxySettings); + Assert.Throws(() => { settings.Socks5ProxySettings = newProxySettings; }); + } + [Fact] public void TestSslSettings() { @@ -1326,6 +1355,7 @@ public void ToClusterKey_should_copy_relevant_values() ServerMonitoringMode = ServerMonitoringMode.Poll, ServerSelectionTimeout = TimeSpan.FromSeconds(6), SocketTimeout = TimeSpan.FromSeconds(4), + Socks5ProxySettings = Socks5ProxySettings.Create("host", 2020, null, null), SslSettings = sslSettings, UseTls = true, #pragma warning disable 618 @@ -1362,6 +1392,7 @@ public void ToClusterKey_should_copy_relevant_values() result.ServerMonitoringMode.Should().Be(ServerMonitoringMode.Poll); result.ServerSelectionTimeout.Should().Be(subject.ServerSelectionTimeout); result.SocketTimeout.Should().Be(subject.SocketTimeout); + result.Socks5ProxySettings.Should().Be(subject.Socks5ProxySettings); result.SslSettings.Should().Be(subject.SslSettings); result.UseTls.Should().Be(subject.UseTls); #pragma warning disable 618 diff --git a/tests/MongoDB.Driver.Tests/Specifications/socks5-support/Socks5SupportProseTests.cs b/tests/MongoDB.Driver.Tests/Specifications/socks5-support/Socks5SupportProseTests.cs new file mode 100644 index 00000000000..4cc21fada48 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Specifications/socks5-support/Socks5SupportProseTests.cs @@ -0,0 +1,80 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver.Core.TestHelpers.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace MongoDB.Driver.Tests.Specifications.socks5_support; + +[Trait("Category", "Integration")] +public class Socks5SupportProseTests(ITestOutputHelper testOutputHelper) : LoggableTestClass(testOutputHelper) +{ + [Theory] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&directConnection=true", false, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&directConnection=true", false, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&directConnection=true", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&directConnection=true", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080", false, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080", false, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=nonexistentuser&proxyPassword=badauth&directConnection=true", false, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=nonexistentuser&proxyPassword=badauth&directConnection=true", false, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&proxyUsername=nonexistentuser&proxyPassword=badauth&directConnection=true", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&proxyUsername=nonexistentuser&proxyPassword=badauth&directConnection=true", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&proxyUsername=nonexistentuser&proxyPassword=badauth", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&proxyUsername=nonexistentuser&proxyPassword=badauth", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=username&proxyPassword=p4ssw0rd&directConnection=true", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=username&proxyPassword=p4ssw0rd&directConnection=true", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&directConnection=true", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081&directConnection=true", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=username&proxyPassword=p4ssw0rd", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1080&proxyUsername=username&proxyPassword=p4ssw0rd", true, true)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081", true, false)] + [InlineData("mongodb:///?proxyHost=localhost&proxyPort=1081", true, true)] + public async Task TestConnectionStrings(string connectionString, bool expectedResult, bool async) + { + //Requires server versions > 5.0 according to spec tests, not sure why + + connectionString = connectionString.Replace("", "localhost:27017").Replace("", "localhost:27017"); + var mongoClientSettings = MongoClientSettings.FromConnectionString(connectionString); + mongoClientSettings.ServerSelectionTimeout = TimeSpan.FromSeconds(1.5); + var client = new MongoClient(mongoClientSettings); + + var database = client.GetDatabase("admin"); + var command = new BsonDocument("hello", 1); + + if (expectedResult) + { + var result = async + ? await database.RunCommandAsync(command) + : database.RunCommand(command); + + Assert.NotEmpty(result); + } + else + { + var exception = async + ? await Record.ExceptionAsync(() => database.RunCommandAsync(command)) + : Record.Exception(() => database.RunCommand(command)); + + Assert.IsType(exception); + } + } +} \ No newline at end of file