Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
services:
# Docker without TLS (plain TCP) !DEPRECATED! with next docker release
docker-no-tls:
image: docker:28.1-dind
env:
DOCKER_TLS_CERTDIR: ""
ports:
- 2375:2375
options: >-
--privileged

# Docker with TLS (secure TCP)
docker-tls:
image: docker:28.1-dind
env:
DOCKER_TLS_CERTDIR: /certs
ports:
- 2376:2376
options: >-
--privileged
volumes:
- ${{ github.workspace }}/certs:/certs

strategy:
matrix:
framework:
Expand All @@ -16,12 +39,53 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
path: test
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.x
- name: Build
run: dotnet build -c Release --framework ${{ matrix.framework }}
working-directory: test

- name: Pack client cert, key, ca for C# docker client
run: |
mkdir -p ${{ github.workspace }}/certs
sudo chmod 777 ${{ github.workspace }}/certs

# create pfx
openssl pkcs12 -export -out ${{ github.workspace }}/certs/client.pfx -inkey ${{ github.workspace }}/certs/client/key.pem -in ${{ github.workspace }}/certs/client/cert.pem -certfile ${{ github.workspace }}/certs/client/ca.pem -passout pass:

- name: Wait for Docker (no TLS) to be healthy
run: |
for i in {1..10}; do
if docker --host=tcp://localhost:2375 version; then
echo "Docker (no TLS) is ready!"
exit 0
fi
echo "Waiting for Docker (no TLS) to be ready..."
sleep 3
done
echo "Docker (no TLS) did not become ready in time."
exit 1

- name: Wait for Docker (with TLS) to be healthy
run: |
for i in {1..10}; do
if docker --host=tcp://localhost:2376 --tlsverify \
--tlscacert=${{ github.workspace }}/certs/client/ca.pem \
--tlscert=${{ github.workspace }}/certs/client/cert.pem \
--tlskey=${{ github.workspace }}/certs/client/key.pem version; then
echo "Docker (TLS) is ready!"
exit 0
fi
echo "Waiting for Docker (TLS) to be ready..."
sleep 3
done
echo "Docker (TLS) did not become ready in time."
exit 1

- name: Test
run: dotnet test -c Release --framework ${{ matrix.framework }} --no-build --logger console
working-directory: test
50 changes: 45 additions & 5 deletions src/Docker.DotNet.X509/CertificateCredentials.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
using System;
using System.Collections.Generic;
using System.Security.Authentication;

namespace Docker.DotNet.X509;

public class CertificateCredentials : Credentials
Expand All @@ -24,17 +28,53 @@ public override bool IsTlsCredentials()

public override HttpMessageHandler GetHandler(HttpMessageHandler handler)
{
if (handler is not ManagedHandler managedHandler)
if (handler is ManagedHandler managedHandler)
{
return handler;
if (!managedHandler.ClientCertificates.Contains(_certificate))
{
managedHandler.ClientCertificates.Add(_certificate);
}

managedHandler.ServerCertificateValidationCallback = ServerCertificateValidationCallback;

return managedHandler;
}

if (!managedHandler.ClientCertificates.Contains(_certificate))
#if NET6_0_OR_GREATER
if (handler is SocketsHttpHandler nativeHandler)
{
managedHandler.ClientCertificates.Add(_certificate);
nativeHandler.UseProxy = true;
nativeHandler.AllowAutoRedirect = true;
nativeHandler.MaxAutomaticRedirections = 20;
nativeHandler.Proxy = WebRequest.DefaultWebProxy;
nativeHandler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection { _certificate },
CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
EnabledSslProtocols = SslProtocols.Tls12,
RemoteCertificateValidationCallback = (message, certificate, chain, errors) => ServerCertificateValidationCallback?.Invoke(message, certificate, chain, errors) ?? false
};
return nativeHandler;
}
#else
if (handler is HttpClientHandler nativeHandler)
{
if (!nativeHandler.ClientCertificates.Contains(_certificate))
{
nativeHandler.ClientCertificates.Add(_certificate);
}

managedHandler.ServerCertificateValidationCallback = ServerCertificateValidationCallback;
nativeHandler.UseProxy = true;
nativeHandler.AllowAutoRedirect = true;
nativeHandler.MaxAutomaticRedirections = 20;
nativeHandler.Proxy = WebRequest.DefaultWebProxy;
nativeHandler.ClientCertificateOptions = ClientCertificateOption.Manual;
nativeHandler.CheckCertificateRevocationList = false;
nativeHandler.SslProtocols = SslProtocols.Tls12;
nativeHandler.ServerCertificateCustomValidationCallback += (message, certificate, chain, errors) => ServerCertificateValidationCallback?.Invoke(message, certificate, chain, errors) ?? false;
return nativeHandler;
}
#endif

return handler;
}
Expand Down
67 changes: 59 additions & 8 deletions src/Docker.DotNet/DockerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand All @@ -44,6 +44,11 @@ internal DockerClient(DockerClientConfiguration configuration, Version requested
throw new Exception("TLS not supported over npipe");
}

if (Configuration.NativeHttpHandler)
{
throw new Exception("Npipe not supported with native handler");
}

var segments = uri.Segments;
if (segments.Length != 3 || !segments[1].Equals("pipe/", StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -77,17 +82,54 @@ await stream.ConnectAsync(timeout, cancellationToken)
case "http":
var builder = new UriBuilder(uri)
{
Scheme = configuration.Credentials.IsTlsCredentials() ? "https" : "http"
Scheme = Configuration.Credentials.IsTlsCredentials() ? "https" : "http"
};
uri = builder.Uri;
handler = new ManagedHandler(logger);
if (Configuration.NativeHttpHandler)
{
#if NET6_0_OR_GREATER
handler = new SocketsHttpHandler()
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 10
};
#else
handler = new HttpClientHandler();
#endif
}
else
{
handler = new ManagedHandler(logger);
}
break;

case "https":
handler = new ManagedHandler(logger);
if (Configuration.NativeHttpHandler)
{
#if NET6_0_OR_GREATER
handler = new SocketsHttpHandler()
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 10
};
#else
handler = new HttpClientHandler();
#endif
}
else
{
handler = new ManagedHandler(logger);
}
break;

case "unix":
if (Configuration.NativeHttpHandler)
{
throw new Exception("Unix sockets not supported with native handler");
}

var pipeString = uri.LocalPath;
handler = new ManagedHandler(async (host, port, cancellationToken) =>
{
Expand All @@ -102,7 +144,7 @@ await sock.ConnectAsync(new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(p
break;

default:
throw new Exception($"Unknown URL scheme {configuration.EndpointBaseUri.Scheme}");
throw new Exception($"Unknown URL scheme {Configuration.EndpointBaseUri.Scheme}");
}

_endpointBaseUri = uri;
Expand Down Expand Up @@ -395,12 +437,21 @@ internal async Task<WriteClosableStream> MakeRequestForHijackedStreamAsync(
await HandleIfErrorResponseAsync(response.StatusCode, response, errorHandlers)
.ConfigureAwait(false);

if (response.Content is not HttpConnectionResponseContent content)
if (Configuration.NativeHttpHandler)
{
throw new NotSupportedException("message handler does not support hijacked streams");
var stream = await response.Content.ReadAsStreamAsync()
.ConfigureAwait(false);
return new WriteClosableStreamWrapper(stream);
}
else
{
if (response.Content is not HttpConnectionResponseContent content)
{
throw new NotSupportedException("message handler does not support hijacked streams");
}

return content.HijackStream();
return content.HijackStream();
}
}

private async Task<HttpResponseMessage> PrivateMakeRequestAsync(
Expand Down
13 changes: 9 additions & 4 deletions src/Docker.DotNet/DockerClientConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ public DockerClientConfiguration(
Credentials credentials = null,
TimeSpan defaultTimeout = default,
TimeSpan namedPipeConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null)
: this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders)
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null,
bool nativeHttpHandler = false)
: this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders, nativeHttpHandler)
{
}

Expand All @@ -18,7 +19,8 @@ public DockerClientConfiguration(
Credentials credentials = null,
TimeSpan defaultTimeout = default,
TimeSpan namedPipeConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null)
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null,
bool nativeHttpHandler = false)
{
if (endpoint == null)
{
Expand All @@ -33,8 +35,9 @@ public DockerClientConfiguration(
EndpointBaseUri = endpoint;
Credentials = credentials ?? new AnonymousCredentials();
DefaultTimeout = TimeSpan.Equals(TimeSpan.Zero, defaultTimeout) ? TimeSpan.FromSeconds(100) : defaultTimeout;
NamedPipeConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, namedPipeConnectTimeout) ? TimeSpan.FromMilliseconds(100) : namedPipeConnectTimeout;
NamedPipeConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, namedPipeConnectTimeout) ? TimeSpan.FromSeconds(10) : namedPipeConnectTimeout;
DefaultHttpRequestHeaders = defaultHttpRequestHeaders ?? new Dictionary<string, string>();
NativeHttpHandler = nativeHttpHandler;
}

/// <summary>
Expand All @@ -50,6 +53,8 @@ public DockerClientConfiguration(

public TimeSpan NamedPipeConnectTimeout { get; }

public bool NativeHttpHandler { get; }

public DockerClient CreateClient(Version requestedApiVersion = null, ILogger logger = null)
{
return new DockerClient(this, requestedApiVersion, logger);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.IO;
using Microsoft.Net.Http.Client;

namespace Microsoft.Net.Http.Client;


public class WriteClosableStreamWrapper : WriteClosableStream
{
private readonly Stream _baseStream;

public WriteClosableStreamWrapper(Stream baseStream)
{
_baseStream = baseStream ?? throw new ArgumentNullException(nameof(baseStream));
}

public override void CloseWrite()
{
_baseStream.Close(); // Replace with half-close logic if available
}

public override bool CanRead => _baseStream.CanRead;
public override bool CanSeek => _baseStream.CanSeek;
public override bool CanWrite => _baseStream.CanWrite;
public override bool CanCloseWrite => true;
public override long Length => _baseStream.Length;

public override long Position
{
get => _baseStream.Position;
set => _baseStream.Position = value;
}

public override void Flush() => _baseStream.Flush();

public override int Read(byte[] buffer, int offset, int count) =>
_baseStream.Read(buffer, offset, count);

public override long Seek(long offset, SeekOrigin origin) =>
_baseStream.Seek(offset, origin);

public override void SetLength(long value) =>
_baseStream.SetLength(value);

public override void Write(byte[] buffer, int offset, int count) =>
_baseStream.Write(buffer, offset, count);

protected override void Dispose(bool disposing)
{
if (disposing)
{
_baseStream.Dispose();
}
base.Dispose(disposing);
}
}
4 changes: 3 additions & 1 deletion test/Docker.DotNet.Tests/CommonCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ public static class CommonCommands
{
public static readonly string[] SleepInfinity = ["/bin/sh", "-c", "trap \"exit 0\" TERM INT; sleep infinity"];

public static readonly string[] EchoToStdoutAndStderr = ["/bin/sh", "-c", "trap \"exit 0\" TERM INT; while true; do echo \"stdout message\"; echo \"stderr message\" >&2; sleep 1; done"];
public static readonly string[] EchoToStdoutAndStderr = ["/bin/sh", "-c", "trap \"exit 0\" TERM INT; RND=$RANDOM; while true; do echo \"stdout message $RND\"; echo \"stderr message $RND\" >&2; sleep 1; done"];

public static readonly string[] EchoToStdoutAndStderrFast = ["/bin/sh", "-c", "trap \"exit 0\" TERM INT; RND=$RANDOM; while true; do echo \"stdout message $RND\"; echo \"stderr message $RND\" >&2; done"];
}
Loading
Loading