diff --git a/src/Docker.DotNet/DockerClient.cs b/src/Docker.DotNet/DockerClient.cs index 2c32db03..2dd1637f 100644 --- a/src/Docker.DotNet/DockerClient.cs +++ b/src/Docker.DotNet/DockerClient.cs @@ -34,7 +34,7 @@ internal DockerClient(DockerClientConfiguration configuration, Version requested Plugin = new PluginOperations(this); Exec = new ExecOperations(this); - ManagedHandler handler; + HttpMessageHandler handler; var uri = Configuration.EndpointBaseUri; switch (uri.Scheme.ToLowerInvariant()) { @@ -60,7 +60,7 @@ internal DockerClient(DockerClientConfiguration configuration, Version requested var pipeName = uri.Segments[2]; uri = new UriBuilder("http", pipeName).Uri; - handler = new ManagedHandler(async (host, port, cancellationToken) => + var pipeHandler = new ManagedHandler(async (host, port, cancellationToken) => { var timeout = (int)Configuration.NamedPipeConnectTimeout.TotalMilliseconds; var stream = new NamedPipeClientStream(serverName, pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); @@ -71,6 +71,9 @@ await stream.ConnectAsync(timeout, cancellationToken) return dockerStream; }, logger); + // Named pipes are local connections - disable proxy resolution + pipeHandler.UseProxy = false; + handler = pipeHandler; break; case "tcp": @@ -80,25 +83,71 @@ await stream.ConnectAsync(timeout, cancellationToken) Scheme = configuration.Credentials.IsTlsCredentials() ? "https" : "http" }; uri = builder.Uri; - handler = new ManagedHandler(logger); + handler = new ManagedHandler(logger, Configuration.SocketConnectionConfiguration); break; case "https": - handler = new ManagedHandler(logger); + handler = new ManagedHandler(logger, Configuration.SocketConnectionConfiguration); break; case "unix": var pipeString = uri.LocalPath; - handler = new ManagedHandler(async (host, port, cancellationToken) => - { - var sock = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - - await sock.ConnectAsync(new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(pipeString)) - .ConfigureAwait(false); - - return sock; - }, logger); + var socketTimeout = Configuration.SocketConnectTimeout; + var socketConfig = Configuration.SocketConnectionConfiguration; uri = new UriBuilder("http", uri.Segments.Last()).Uri; + + // Use ManagedHandler for Unix socket connections. + // ManagedHandler is required for hijacked stream operations (attach/exec/logs) + // as it provides HttpConnectionResponseContent needed for connection hijacking. + // SocketsHttpHandler cannot support hijacking because it encapsulates the transport stream. + var unixHandler = new ManagedHandler(async (_, _, cancellationToken) => + { + var endpoint = new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(pipeString); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + + try + { + // Apply socket configuration for better proxy compatibility + socketConfig.ApplyTo(socket); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(socketTimeout); + +#if NET5_0_OR_GREATER + // Use modern ConnectAsync with cancellation support + await socket.ConnectAsync(endpoint, timeoutCts.Token) + .ConfigureAwait(false); +#else + var connectTask = socket.ConnectAsync(endpoint); + var timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, timeoutCts.Token); + + var completedTask = await Task.WhenAny(connectTask, timeoutTask) + .ConfigureAwait(false); + + if (completedTask == timeoutTask) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new TimeoutException($"Connection to Unix socket '{pipeString}' timed out after {socketTimeout.TotalSeconds}s."); + } + + await connectTask.ConfigureAwait(false); +#endif + return socket; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + socket.Dispose(); + throw new TimeoutException($"Connection to Unix socket '{pipeString}' timed out after {socketTimeout.TotalSeconds}s."); + } + catch + { + socket.Dispose(); + throw; + } + }, logger, socketConfig); + // Unix sockets are local connections - disable proxy resolution + unixHandler.UseProxy = false; + handler = unixHandler; break; default: diff --git a/src/Docker.DotNet/DockerClientConfiguration.cs b/src/Docker.DotNet/DockerClientConfiguration.cs index a1fb82d3..9e6b12d3 100644 --- a/src/Docker.DotNet/DockerClientConfiguration.cs +++ b/src/Docker.DotNet/DockerClientConfiguration.cs @@ -8,8 +8,10 @@ public DockerClientConfiguration( Credentials credentials = null, TimeSpan defaultTimeout = default, TimeSpan namedPipeConnectTimeout = default, - IReadOnlyDictionary defaultHttpRequestHeaders = null) - : this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders) + TimeSpan socketConnectTimeout = default, + IReadOnlyDictionary defaultHttpRequestHeaders = null, + SocketConnectionConfiguration socketConfiguration = null) + : this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, socketConnectTimeout, defaultHttpRequestHeaders, socketConfiguration) { } @@ -18,7 +20,9 @@ public DockerClientConfiguration( Credentials credentials = null, TimeSpan defaultTimeout = default, TimeSpan namedPipeConnectTimeout = default, - IReadOnlyDictionary defaultHttpRequestHeaders = null) + TimeSpan socketConnectTimeout = default, + IReadOnlyDictionary defaultHttpRequestHeaders = null, + SocketConnectionConfiguration socketConfiguration = null) { if (endpoint == null) { @@ -34,22 +38,47 @@ public DockerClientConfiguration( Credentials = credentials ?? new AnonymousCredentials(); DefaultTimeout = TimeSpan.Equals(TimeSpan.Zero, defaultTimeout) ? TimeSpan.FromSeconds(100) : defaultTimeout; NamedPipeConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, namedPipeConnectTimeout) ? TimeSpan.FromMilliseconds(100) : namedPipeConnectTimeout; + SocketConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, socketConnectTimeout) ? TimeSpan.FromSeconds(30) : socketConnectTimeout; DefaultHttpRequestHeaders = defaultHttpRequestHeaders ?? new Dictionary(); + SocketConnectionConfiguration = socketConfiguration ?? SocketConnectionConfiguration.Default; } /// - /// Gets the collection of default HTTP request headers. + /// Gets the Docker endpoint base URI. /// - public IReadOnlyDictionary DefaultHttpRequestHeaders { get; } - public Uri EndpointBaseUri { get; } + /// + /// Gets the credentials used for authentication. + /// public Credentials Credentials { get; } + /// + /// Gets the default timeout for API requests. + /// public TimeSpan DefaultTimeout { get; } + /// + /// Gets the timeout for named pipe connections (Windows). + /// public TimeSpan NamedPipeConnectTimeout { get; } + /// + /// Gets the timeout for Unix domain socket connections. + /// + public TimeSpan SocketConnectTimeout { get; } + + /// + /// Gets the socket configuration options for connection handling. + /// These settings help improve proxy compatibility and connection reliability. + /// + public SocketConnectionConfiguration SocketConnectionConfiguration { get; } + + /// + /// Gets the collection of default HTTP request headers. + /// + public IReadOnlyDictionary DefaultHttpRequestHeaders { get; } + public DockerClient CreateClient(Version requestedApiVersion = null, ILogger logger = null) { return new DockerClient(this, requestedApiVersion, logger); diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/BufferedReadStream.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/BufferedReadStream.cs index d59b1db3..f69e45bd 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/BufferedReadStream.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/BufferedReadStream.cs @@ -9,6 +9,7 @@ internal sealed class BufferedReadStream : WriteClosableStream, IPeekableStream private int _bufferRefCount; private int _bufferOffset; private int _bufferCount; + private bool _disposed; public BufferedReadStream(Stream inner, Socket socket, ILogger logger) : this(inner, socket, 8192, logger) @@ -59,8 +60,14 @@ public override long Position protected override void Dispose(bool disposing) { + if (_disposed) + { + return; + } + if (disposing) { + _disposed = true; if (Interlocked.Exchange(ref _bufferRefCount, 0) == 1) { ArrayPool.Shared.Return(_buffer); diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedReadStream.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedReadStream.cs index e4991829..0ed59f71 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedReadStream.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedReadStream.cs @@ -5,6 +5,7 @@ internal sealed class ChunkedReadStream : Stream private readonly BufferedReadStream _inner; private int _chunkBytesRemaining; private bool _done; + private bool _disposed; public ChunkedReadStream(BufferedReadStream stream) { @@ -146,4 +147,14 @@ public override void Flush() { _inner.Flush(); } -} \ No newline at end of file + + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + // Note: We don't dispose _inner here as it's owned by HttpConnection + } + base.Dispose(disposing); + } +} diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedWriteStream.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedWriteStream.cs index 4a1ca6c0..2bf147b7 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedWriteStream.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedWriteStream.cs @@ -5,6 +5,7 @@ internal sealed class ChunkedWriteStream : Stream private static readonly byte[] EndOfContentBytes = Encoding.ASCII.GetBytes("0\r\n\r\n"); private readonly Stream _inner; + private bool _disposed; public ChunkedWriteStream(Stream stream) { @@ -87,4 +88,14 @@ public Task EndContentAsync(CancellationToken cancellationToken) { return _inner.WriteAsync(EndOfContentBytes, 0, EndOfContentBytes.Length, cancellationToken); } -} \ No newline at end of file + + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + // Note: We don't dispose _inner here as it's owned by the caller + } + base.Dispose(disposing); + } +} diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/ContentLengthReadStream.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/ContentLengthReadStream.cs index 2d38d85c..321110fb 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/ContentLengthReadStream.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/ContentLengthReadStream.cs @@ -122,8 +122,9 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, protected override void Dispose(bool disposing) { - if (disposing) + if (disposing && !_disposed) { + _disposed = true; // TODO: Sync drain with timeout if small number of bytes remaining? This will let us re-use the connection. _inner.Dispose(); } diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnection.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnection.cs index 22972f8b..f4ee563a 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnection.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnection.cs @@ -18,27 +18,35 @@ public async Task SendAsync(HttpRequestMessage request, Can // Serialize headers & send string rawRequest = SerializeRequest(request); byte[] requestBytes = Encoding.ASCII.GetBytes(rawRequest); - await Transport.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken); + await Transport.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken).ConfigureAwait(false); if (request.Content != null) { if (request.Content.Headers.ContentLength.HasValue) { - await request.Content.CopyToAsync(Transport); +#if NET5_0_OR_GREATER + await request.Content.CopyToAsync(Transport, cancellationToken).ConfigureAwait(false); +#else + await request.Content.CopyToAsync(Transport).ConfigureAwait(false); +#endif } else { // The length of the data is unknown. Send it in chunked mode. using (var chunkedStream = new ChunkedWriteStream(Transport)) { - await request.Content.CopyToAsync(chunkedStream); - await chunkedStream.EndContentAsync(cancellationToken); +#if NET5_0_OR_GREATER + await request.Content.CopyToAsync(chunkedStream, cancellationToken).ConfigureAwait(false); +#else + await request.Content.CopyToAsync(chunkedStream).ConfigureAwait(false); +#endif + await chunkedStream.EndContentAsync(cancellationToken).ConfigureAwait(false); } } } // Receive headers - List responseLines = await ReadResponseLinesAsync(cancellationToken); + List responseLines = await ReadResponseLinesAsync(cancellationToken).ConfigureAwait(false); // Receive body and determine the response type (Content-Length, Transfer-Encoding, Opaque) return CreateResponseMessage(responseLines); diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnectionResponseContent.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnectionResponseContent.cs index 01a8ea16..9d4435c3 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnectionResponseContent.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnectionResponseContent.cs @@ -62,7 +62,7 @@ protected override void Dispose(bool disposing) { if (disposing) { - _responseStream.Dispose(); + _responseStream?.Dispose(); _connection.Dispose(); } } @@ -71,4 +71,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } -} \ No newline at end of file +} diff --git a/src/Docker.DotNet/Microsoft.Net.Http.Client/ManagedHandler.cs b/src/Docker.DotNet/Microsoft.Net.Http.Client/ManagedHandler.cs index 728cf6eb..12983917 100644 --- a/src/Docker.DotNet/Microsoft.Net.Http.Client/ManagedHandler.cs +++ b/src/Docker.DotNet/Microsoft.Net.Http.Client/ManagedHandler.cs @@ -1,6 +1,7 @@ namespace Microsoft.Net.Http.Client; using System; +using Docker.DotNet; public class ManagedHandler : HttpMessageHandler { @@ -10,6 +11,8 @@ public class ManagedHandler : HttpMessageHandler private readonly SocketOpener _socketOpener; + private readonly SocketConnectionConfiguration _socketConfiguration; + private IWebProxy _proxy; public delegate Task StreamOpener(string host, int port, CancellationToken cancellationToken); @@ -17,20 +20,35 @@ public class ManagedHandler : HttpMessageHandler public delegate Task SocketOpener(string host, int port, CancellationToken cancellationToken); public ManagedHandler(ILogger logger) + : this(logger, SocketConnectionConfiguration.Default) + { + } + + public ManagedHandler(ILogger logger, SocketConnectionConfiguration socketConfiguration) { _logger = logger; + _socketConfiguration = socketConfiguration ?? SocketConnectionConfiguration.Default; _socketOpener = TcpSocketOpenerAsync; } public ManagedHandler(StreamOpener opener, ILogger logger) { _logger = logger; + _socketConfiguration = SocketConnectionConfiguration.Default; _streamOpener = opener ?? throw new ArgumentNullException(nameof(opener)); } public ManagedHandler(SocketOpener opener, ILogger logger) { _logger = logger; + _socketConfiguration = SocketConnectionConfiguration.Default; + _socketOpener = opener ?? throw new ArgumentNullException(nameof(opener)); + } + + public ManagedHandler(SocketOpener opener, ILogger logger, SocketConnectionConfiguration socketConfiguration) + { + _logger = logger; + _socketConfiguration = socketConfiguration ?? SocketConnectionConfiguration.Default; _socketOpener = opener ?? throw new ArgumentNullException(nameof(opener)); } @@ -40,7 +58,12 @@ public IWebProxy Proxy { if (_proxy == null) { +#if NET6_0_OR_GREATER + // Use modern HttpClient.DefaultProxy for better proxy resolution + _proxy = HttpClient.DefaultProxy; +#else _proxy = WebRequest.DefaultWebProxy; +#endif } return _proxy; @@ -172,9 +195,7 @@ private async Task ProcessRequestAsync(HttpRequestMessage r if (request.IsHttps()) { - SslStream sslStream = new SslStream(transport, false, ServerCertificateValidationCallback); - await sslStream.AuthenticateAsClientAsync(request.GetHostProperty(), ClientCertificates, SslProtocols.Tls12, false); - transport = sslStream; + transport = await EstablishSslAsync(transport, request.GetHostProperty(), cancellationToken).ConfigureAwait(false); } var bufferedReadStream = new BufferedReadStream(transport, socket, _logger); @@ -182,6 +203,37 @@ private async Task ProcessRequestAsync(HttpRequestMessage r return await connection.SendAsync(request, cancellationToken); } + private async Task EstablishSslAsync(Stream transport, string targetHost, CancellationToken cancellationToken) + { + var sslStream = new SslStream(transport, false, ServerCertificateValidationCallback); + + try + { +#if NET5_0_OR_GREATER + // Use modern SslClientAuthenticationOptions for better TLS configuration + var sslOptions = new SslClientAuthenticationOptions + { + TargetHost = targetHost, + ClientCertificates = ClientCertificates, + // Let the OS choose the best protocol (TLS 1.2/1.3) + EnabledSslProtocols = SslProtocols.None, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck + }; + + await sslStream.AuthenticateAsClientAsync(sslOptions, cancellationToken).ConfigureAwait(false); +#else + // Fallback for older frameworks - use TLS 1.2 as minimum + await sslStream.AuthenticateAsClientAsync(targetHost, ClientCertificates, SslProtocols.Tls12, checkCertificateRevocation: false).ConfigureAwait(false); +#endif + return sslStream; + } + catch + { + sslStream.Dispose(); + throw; + } + } + // Data comes from either the request.RequestUri or from the request.Properties private static void ProcessUrl(HttpRequestMessage request) { @@ -315,16 +367,43 @@ private ProxyMode DetermineProxyModeAndAddressLine(HttpRequestMessage request) return ProxyMode.Tunnel; } - private static async Task TcpSocketOpenerAsync(string host, int port, CancellationToken cancellationToken) + private async Task TcpSocketOpenerAsync(string host, int port, CancellationToken cancellationToken) { +#if NET5_0_OR_GREATER + // Use modern DNS resolution with cancellation support + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken) + .ConfigureAwait(false); +#else var addresses = await Dns.GetHostAddressesAsync(host) .ConfigureAwait(false); +#endif if (addresses.Length == 0) { throw new Exception($"Unable to resolve any IP addresses for the host '{host}'."); } +#if NET6_0_OR_GREATER + // Use Happy Eyeballs if enabled and we have both IPv6 and IPv4 addresses + if (_socketConfiguration.EnableHappyEyeballs) + { + var ipv6Addresses = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).ToArray(); + var ipv4Addresses = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToArray(); + + if (ipv6Addresses.Length > 0 && ipv4Addresses.Length > 0) + { + return await ConnectWithHappyEyeballsAsync(ipv6Addresses, ipv4Addresses, port, cancellationToken) + .ConfigureAwait(false); + } + } +#endif + + // Fallback to sequential connection attempts + return await ConnectSequentialAsync(addresses, port, cancellationToken).ConfigureAwait(false); + } + + private async Task ConnectSequentialAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken) + { var exceptions = new List(); foreach (var address in addresses) @@ -333,8 +412,17 @@ private static async Task TcpSocketOpenerAsync(string host, int port, Ca try { + // Apply socket configuration for better proxy compatibility + _socketConfiguration.ApplyTo(socket); + +#if NET5_0_OR_GREATER + // Use modern ConnectAsync with cancellation support + await socket.ConnectAsync(address, port, cancellationToken) + .ConfigureAwait(false); +#else await socket.ConnectAsync(address, port) .ConfigureAwait(false); +#endif return socket; } @@ -348,6 +436,141 @@ await socket.ConnectAsync(address, port) throw new AggregateException(exceptions); } +#if NET6_0_OR_GREATER + private async Task ConnectWithHappyEyeballsAsync( + IPAddress[] ipv6Addresses, + IPAddress[] ipv4Addresses, + int port, + CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var exceptions = new ConcurrentBag(); + Socket winningSocket = null; + + // Start IPv6 connection attempts + var ipv6Task = Task.Run(async () => + { + try + { + return await ConnectToAddressesAsync(ipv6Addresses, port, cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + exceptions.Add(ex); + return null; + } + }, cts.Token); + + // Start IPv4 connection after delay (Happy Eyeballs delay) + var ipv4Task = Task.Run(async () => + { + try + { + await Task.Delay(_socketConfiguration.HappyEyeballsDelay, cts.Token).ConfigureAwait(false); + + // Only proceed if IPv6 hasn't connected yet + if (!ipv6Task.IsCompleted) + { + return await ConnectToAddressesAsync(ipv4Addresses, port, cts.Token).ConfigureAwait(false); + } + + return null; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + exceptions.Add(ex); + return null; + } + }, cts.Token); + + // Keep track of all tasks for cleanup + var allTasks = new List> { ipv6Task, ipv4Task }; + var pendingTasks = new List>(allTasks); + + while (pendingTasks.Count > 0) + { + var completedTask = await Task.WhenAny(pendingTasks).ConfigureAwait(false); + pendingTasks.Remove(completedTask); + + try + { + var socket = await completedTask.ConfigureAwait(false); + if (socket != null && socket.Connected) + { + winningSocket = socket; + cts.Cancel(); // Cancel the other connection attempt + break; + } + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + // Clean up any remaining sockets from tasks that didn't win + foreach (var task in allTasks) + { + // Skip the winning task + if (winningSocket != null && task.IsCompletedSuccessfully) + { + try + { + var socket = await task.ConfigureAwait(false); + if (socket != null && socket != winningSocket) + { + socket.Dispose(); + } + } + catch + { + // Ignore cleanup errors + } + } + } + + if (winningSocket != null) + { + return winningSocket; + } + + throw new AggregateException("Failed to connect using Happy Eyeballs.", exceptions); + } + + private async Task ConnectToAddressesAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken) + { + foreach (var address in addresses) + { + cancellationToken.ThrowIfCancellationRequested(); + + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + try + { + _socketConfiguration.ApplyTo(socket); + await socket.ConnectAsync(address, port, cancellationToken).ConfigureAwait(false); + return socket; + } + catch (OperationCanceledException) + { + socket.Dispose(); + throw; + } + catch + { + socket.Dispose(); + // Try next address + } + } + + return null; + } +#endif + private async Task TunnelThroughProxyAsync(HttpRequestMessage request, Stream transport, CancellationToken cancellationToken) { // Send a Connect request: @@ -384,4 +607,9 @@ private async Task TunnelThroughProxyAsync(HttpRequestMessage request, Stream tr throw new HttpRequestException("Failed to negotiate the proxy tunnel: " + connectResponse); } } -} \ No newline at end of file + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } +} diff --git a/src/Docker.DotNet/SocketConnectionConfiguration.cs b/src/Docker.DotNet/SocketConnectionConfiguration.cs new file mode 100644 index 00000000..1d185392 --- /dev/null +++ b/src/Docker.DotNet/SocketConnectionConfiguration.cs @@ -0,0 +1,178 @@ +namespace Docker.DotNet; + +using System; + +/// +/// Configuration options for socket connections. +/// These settings help improve proxy compatibility and connection reliability. +/// +public sealed class SocketConnectionConfiguration +{ + /// + /// Default socket connection configuration with sensible defaults for Docker socket connections. + /// + public static SocketConnectionConfiguration Default { get; } = new SocketConnectionConfiguration(); + + /// + /// Gets or sets whether TCP keep-alive is enabled. + /// Default is true for better proxy compatibility. + /// + public bool KeepAlive { get; set; } = true; + + /// + /// Gets or sets the time (in seconds) the connection needs to remain idle + /// before TCP starts sending keep-alive probes. + /// Only applies when is true. + /// Default is 30 seconds. + /// + /// + /// This setting requires .NET 5.0+ for TCP sockets. On Unix sockets, this is ignored. + /// + public int KeepAliveTime { get; set; } = 30; + + /// + /// Gets or sets the interval (in seconds) between keep-alive probes. + /// Only applies when is true. + /// Default is 10 seconds. + /// + /// + /// This setting requires .NET 5.0+ for TCP sockets. On Unix sockets, this is ignored. + /// + public int KeepAliveInterval { get; set; } = 10; + + /// + /// Gets or sets the number of keep-alive probes to send before considering the connection dead. + /// Only applies when is true. + /// Default is 3 retries. + /// + /// + /// This setting requires .NET 7.0+ for TCP sockets. On Unix sockets, this is ignored. + /// + public int KeepAliveRetryCount { get; set; } = 3; + + /// + /// Gets or sets whether to disable the Nagle algorithm (TCP_NODELAY). + /// Default is true for lower latency with Docker API calls. + /// + /// + /// Disabling Nagle's algorithm reduces latency for small packets at the cost of + /// potentially increased network traffic. This is generally desirable for Docker API + /// communication which consists of many small request/response pairs. + /// + public bool NoDelay { get; set; } = true; + + /// + /// Gets or sets the socket send buffer size in bytes. + /// Default is null (uses system default). + /// + public int? SendBufferSize { get; set; } + + /// + /// Gets or sets the socket receive buffer size in bytes. + /// Default is null (uses system default). + /// + public int? ReceiveBufferSize { get; set; } + +#if NET6_0_OR_GREATER + /// + /// Gets or sets whether Happy Eyeballs (RFC 8305) is enabled for dual-stack hosts. + /// When enabled, IPv6 and IPv4 connections are raced to find the fastest route. + /// Default is true. + /// + /// + /// Happy Eyeballs improves connection times on dual-stack networks by attempting + /// IPv6 first, then starting an IPv4 connection after a short delay if IPv6 + /// hasn't connected yet, and using whichever connects first. + /// + public bool EnableHappyEyeballs { get; set; } = true; + + /// + /// Gets or sets the delay before starting an IPv4 connection when Happy Eyeballs is enabled. + /// Default is 250ms as recommended by RFC 8305. + /// + public TimeSpan HappyEyeballsDelay { get; set; } = TimeSpan.FromMilliseconds(250); +#endif + + /// + /// Applies the configuration to a socket. + /// + /// The socket to configure. + /// Thrown when socket is null. + public void ApplyTo(System.Net.Sockets.Socket socket) + { + if (socket == null) + { + throw new ArgumentNullException(nameof(socket)); + } + + // Apply KeepAlive - works on all platforms + if (KeepAlive) + { + socket.SetSocketOption( + System.Net.Sockets.SocketOptionLevel.Socket, + System.Net.Sockets.SocketOptionName.KeepAlive, + true); + } + + // Apply TCP-specific options only for TCP sockets (not Unix domain sockets) + if (socket.ProtocolType == System.Net.Sockets.ProtocolType.Tcp) + { + if (NoDelay) + { + socket.NoDelay = true; + } + +#if NET5_0_OR_GREATER + // TCP KeepAlive time and interval (requires .NET 5.0+) + if (KeepAlive) + { + try + { + socket.SetSocketOption( + System.Net.Sockets.SocketOptionLevel.Tcp, + System.Net.Sockets.SocketOptionName.TcpKeepAliveTime, + KeepAliveTime); + + socket.SetSocketOption( + System.Net.Sockets.SocketOptionLevel.Tcp, + System.Net.Sockets.SocketOptionName.TcpKeepAliveInterval, + KeepAliveInterval); + } + catch (System.Net.Sockets.SocketException) + { + // These options may not be available on all platforms, ignore failures + } + } +#endif + +#if NET7_0_OR_GREATER + // TCP KeepAlive retry count (requires .NET 7.0+) + if (KeepAlive) + { + try + { + socket.SetSocketOption( + System.Net.Sockets.SocketOptionLevel.Tcp, + System.Net.Sockets.SocketOptionName.TcpKeepAliveRetryCount, + KeepAliveRetryCount); + } + catch (System.Net.Sockets.SocketException) + { + // This option may not be available on all platforms, ignore failures + } + } +#endif + } + + // Apply buffer sizes if specified + if (SendBufferSize.HasValue) + { + socket.SendBufferSize = SendBufferSize.Value; + } + + if (ReceiveBufferSize.HasValue) + { + socket.ReceiveBufferSize = ReceiveBufferSize.Value; + } + } +} diff --git a/test/Docker.DotNet.Tests/DockerClientConfigurationTests.cs b/test/Docker.DotNet.Tests/DockerClientConfigurationTests.cs new file mode 100644 index 00000000..d8593c37 --- /dev/null +++ b/test/Docker.DotNet.Tests/DockerClientConfigurationTests.cs @@ -0,0 +1,39 @@ +namespace Docker.DotNet.Tests; + +public class DockerClientConfigurationTests +{ + [Fact] + public void SocketConnectTimeout_DefaultValue_Is30Seconds() + { + using var config = new DockerClientConfiguration(); + + Assert.Equal(TimeSpan.FromSeconds(30), config.SocketConnectTimeout); + } + + [Fact] + public void SocketConnectTimeout_CustomValue_IsPreserved() + { + var customTimeout = TimeSpan.FromSeconds(60); + + using var config = new DockerClientConfiguration( + socketConnectTimeout: customTimeout); + + Assert.Equal(customTimeout, config.SocketConnectTimeout); + } + + [Fact] + public void NamedPipeConnectTimeout_DefaultValue_Is100Milliseconds() + { + using var config = new DockerClientConfiguration(); + + Assert.Equal(TimeSpan.FromMilliseconds(100), config.NamedPipeConnectTimeout); + } + + [Fact] + public void DefaultTimeout_DefaultValue_Is100Seconds() + { + using var config = new DockerClientConfiguration(); + + Assert.Equal(TimeSpan.FromSeconds(100), config.DefaultTimeout); + } +} diff --git a/test/Docker.DotNet.Tests/IContainerOperationsTests.cs b/test/Docker.DotNet.Tests/IContainerOperationsTests.cs index cc2ae303..e22cadfc 100644 --- a/test/Docker.DotNet.Tests/IContainerOperationsTests.cs +++ b/test/Docker.DotNet.Tests/IContainerOperationsTests.cs @@ -190,7 +190,7 @@ await _testFixture.DockerClient.Containers.StartContainerAsync( containerLogsCts.CancelAfter(TimeSpan.FromSeconds(5)); - await Assert.ThrowsAsync(() => _testFixture.DockerClient.Containers.GetContainerLogsAsync( + await Assert.ThrowsAnyAsync(() => _testFixture.DockerClient.Containers.GetContainerLogsAsync( createContainerResponse.ID, new ContainerLogsParameters { @@ -240,7 +240,7 @@ await _testFixture.DockerClient.Containers.StartContainerAsync( containerLogsCts.Token ); - await Assert.ThrowsAsync(() => containerLogsTask); + await Assert.ThrowsAnyAsync(() => containerLogsTask); } [Fact] @@ -288,7 +288,7 @@ await _testFixture.DockerClient.Containers.StopContainerAsync( _testFixture.Cts.Token ); - await Assert.ThrowsAsync(() => containerLogsTask); + await Assert.ThrowsAnyAsync(() => containerLogsTask); _testOutputHelper.WriteLine($"Line count: {logList.Count}"); Assert.NotEmpty(logList); diff --git a/test/Docker.DotNet.Tests/ManagedHandlerTests.cs b/test/Docker.DotNet.Tests/ManagedHandlerTests.cs new file mode 100644 index 00000000..43854395 --- /dev/null +++ b/test/Docker.DotNet.Tests/ManagedHandlerTests.cs @@ -0,0 +1,148 @@ +namespace Docker.DotNet.Tests; + +using System.IO; +using System.Net.Sockets; +using System.Text; +using Microsoft.Net.Http.Client; +using Xunit; + +/// +/// Tests for ManagedHandler modernization features. +/// +public class ManagedHandlerTests +{ + #region Happy Eyeballs Tests + +#if NET6_0_OR_GREATER + [Fact] + public void SocketConnectionConfiguration_HappyEyeballs_DefaultEnabled() + { + // Arrange & Act + var config = new SocketConnectionConfiguration(); + + // Assert + Assert.True(config.EnableHappyEyeballs); + Assert.Equal(TimeSpan.FromMilliseconds(250), config.HappyEyeballsDelay); + } + + [Fact] + public void SocketConnectionConfiguration_HappyEyeballs_CanBeDisabled() + { + // Arrange + var config = new SocketConnectionConfiguration(); + + // Act + config.EnableHappyEyeballs = false; + + // Assert + Assert.False(config.EnableHappyEyeballs); + } + + [Fact] + public void SocketConnectionConfiguration_HappyEyeballsDelay_CanBeCustomized() + { + // Arrange + var config = new SocketConnectionConfiguration(); + + // Act + config.HappyEyeballsDelay = TimeSpan.FromMilliseconds(500); + + // Assert + Assert.Equal(TimeSpan.FromMilliseconds(500), config.HappyEyeballsDelay); + } +#endif + + #endregion + + #region Phase 6: Modern Proxy Resolution Tests + + [Fact] + public void ManagedHandler_Proxy_CanBeSet() + { + // Arrange + var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); + using var handler = new ManagedHandler(logger); + var proxy = new System.Net.WebProxy("http://proxy.example.com:8080"); + + // Act + handler.Proxy = proxy; + + // Assert + Assert.Equal(proxy, handler.Proxy); + } + + [Fact] + public void ManagedHandler_UseProxy_DefaultTrue() + { + // Arrange & Act + var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); + using var handler = new ManagedHandler(logger); + + // Assert + Assert.True(handler.UseProxy); + } + + #endregion + + #region Phase 7: Line Reading Tests + +#if NET6_0_OR_GREATER + [Fact] + public async Task BufferedReadStream_ReadLineAsync_ReadsLine() + { + // Arrange + var testData = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + var mockStream = new MemoryStream(Encoding.ASCII.GetBytes(testData)); + var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); + using var bufferedStream = new BufferedReadStream(mockStream, null, logger); + + // Act + var line = await bufferedStream.ReadLineAsync(CancellationToken.None); + + // Assert + Assert.Equal("HTTP/1.1 200 OK", line); + } +#endif + + #endregion + + #region Integration Tests + + [Fact] + public void ManagedHandler_FullConfiguration_AllPropertiesAccessible() + { + // Arrange & Act + var logger = new Microsoft.Extensions.Logging.Abstractions.NullLogger(); + using var handler = new ManagedHandler(logger); + + // Assert - All properties should be accessible + Assert.NotNull(handler.Proxy); + Assert.True(handler.UseProxy); + Assert.Equal(20, handler.MaxAutomaticRedirects); + Assert.Equal(RedirectMode.NoDowngrade, handler.RedirectMode); + Assert.Null(handler.ServerCertificateValidationCallback); + Assert.NotNull(handler.ClientCertificates); + } + + [Fact] + public void SocketConnectionConfiguration_FullConfiguration_AllPropertiesAccessible() + { + // Arrange & Act + var config = new SocketConnectionConfiguration(); + + // Assert - All properties should be accessible + Assert.True(config.KeepAlive); + Assert.Equal(30, config.KeepAliveTime); + Assert.Equal(10, config.KeepAliveInterval); + Assert.Equal(3, config.KeepAliveRetryCount); + Assert.True(config.NoDelay); + Assert.Null(config.SendBufferSize); + Assert.Null(config.ReceiveBufferSize); +#if NET6_0_OR_GREATER + Assert.True(config.EnableHappyEyeballs); + Assert.Equal(TimeSpan.FromMilliseconds(250), config.HappyEyeballsDelay); +#endif + } + + #endregion +} diff --git a/test/Docker.DotNet.Tests/TestFixture.cs b/test/Docker.DotNet.Tests/TestFixture.cs index b32ecd6e..64a606d9 100644 --- a/test/Docker.DotNet.Tests/TestFixture.cs +++ b/test/Docker.DotNet.Tests/TestFixture.cs @@ -116,6 +116,12 @@ await DockerClient.Swarm.LeaveSwarmAsync(new SwarmLeaveParameters { Force = true .ConfigureAwait(false); } + // If InitializeAsync failed, Image may be null + if (Image == null) + { + return; + } + var containers = await DockerClient.Containers.ListContainersAsync( new ContainersListParameters {