Skip to content

Commit

Permalink
feat(testcontainers#370): Added support docker mTLS endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
vlaskal committed Oct 9, 2022
1 parent 9dd775f commit b787bfc
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 10 deletions.
2 changes: 2 additions & 0 deletions Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
<GlobalPackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" />
<PackageReference Update="JetBrains.Annotations" Version="2022.1.0" PrivateAssets="all" />
<PackageReference Update="Docker.DotNet" Version="3.125.12" />
<PackageReference Update="Docker.DotNet.X509" Version="3.125.12" />
<PackageReference Update="Microsoft.Bcl.AsyncInterfaces" Version="1.1.1" />
<PackageReference Update="Microsoft.Extensions.Logging.Abstractions" Version="3.1.26" />
<PackageReference Update="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Update="SharpZipLib" Version="1.4.0" />
<PackageReference Update="System.Text.Json" Version="4.7.2" />
<!-- Unit and integration test dependencies: -->
Expand Down
138 changes: 138 additions & 0 deletions src/Testcontainers/Builders/MTlsEndpointAuthenticationProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace DotNet.Testcontainers.Builders
{
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Docker.DotNet.X509;
using DotNet.Testcontainers.Configurations;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Pkcs;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.X509;
using MSX509 = System.Security.Cryptography.X509Certificates;

/// <inheritdoc cref="IDockerRegistryAuthenticationProvider" />
internal sealed class MTlsEndpointAuthenticationProvider : TlsEndpointAuthenticationProvider
{
private const string DefaultClientCertFileName = "cert.pem";
private const string DefaultClientKeyFileName = "key.pem";
private static readonly Regex PemData = new Regex("-----BEGIN (.*)-----(.*)-----END (.*)-----", RegexOptions.Multiline);

private readonly Lazy<MSX509.X509Certificate2> clientCertificate;
private readonly string dockerClientCertFile;
private readonly string dockerClientKeyFile;
private readonly bool dockerTlsVerifyEnabled;

/// <summary>
/// Initializes a new instance of the <see cref="MTlsEndpointAuthenticationProvider" /> class.
/// </summary>
public MTlsEndpointAuthenticationProvider()
: this(PropertiesFileConfiguration.Instance, EnvironmentConfiguration.Instance)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="MTlsEndpointAuthenticationProvider" /> class.
/// </summary>
/// <param name="customConfigurations">A list of custom configurations.</param>
public MTlsEndpointAuthenticationProvider(params ICustomConfiguration[] customConfigurations)
: base(customConfigurations)
{
this.dockerTlsVerifyEnabled = customConfigurations
.Select(customConfiguration => customConfiguration.GetDockerTlsVerify())
.Aggregate(false, (x, y) => x || y);
this.dockerClientCertFile = Path.Combine(this.DockerCertPath, DefaultClientCertFileName);
this.dockerClientKeyFile = Path.Combine(this.DockerCertPath, DefaultClientKeyFileName);
this.clientCertificate = new Lazy<MSX509.X509Certificate2>(this.GetClientCertificate);
}

/// <inheritdoc />
public override bool IsApplicable()
{
return this.dockerTlsVerifyEnabled;
}

/// <inheritdoc />
public override IDockerEndpointAuthenticationConfiguration GetAuthConfig()
{
var certificateCredentials = new CertificateCredentials(this.clientCertificate.Value);
certificateCredentials.ServerCertificateValidationCallback = this.ServerCertificateValidationCallback;
return new DockerEndpointAuthenticationConfiguration(this.DockerEngine, certificateCredentials);
}

private static MSX509.X509Certificate2 GetCertificateWithKey(X509Certificate certificate, AsymmetricKeyParameter privateKey)
{
var store = new Pkcs12StoreBuilder()
.SetUseDerEncoding(true)
.Build();

var certEntry = new X509CertificateEntry(certificate);
store.SetKeyEntry(certificate.SubjectDN.ToString(), new AsymmetricKeyEntry(privateKey), new[] { certEntry });

byte[] pfxBytes;
var password = Guid.NewGuid().ToString("N");

using (var stream = new MemoryStream())
{
store.Save(stream, password.ToCharArray(), new SecureRandom());
pfxBytes = stream.ToArray();
}

var result = Pkcs12Utilities.ConvertToDefiniteLength(pfxBytes);

return new MSX509.X509Certificate2(result, password, MSX509.X509KeyStorageFlags.Exportable);
}

private static X509Certificate ReadPemCert(string pathToPemFile)
{
var x509CertificateParser = new X509CertificateParser();
var cert = x509CertificateParser.ReadCertificate(File.ReadAllBytes(pathToPemFile));

return cert;
}

private static AsymmetricKeyParameter ReadPemRsaPrivateKey(string pathToPemFile)
{
var keyData = File.ReadAllText(pathToPemFile)
.Replace("\n", string.Empty);

var keyMatch = PemData.Match(keyData);
if (!keyMatch.Success)
{
throw new NotSupportedException("Not supported key content");
}

if (keyMatch.Groups[1].Value != "RSA PRIVATE KEY")
{
throw new NotSupportedException("Not supported key type. Only RSA PRIVATE KEY is supported.");
}

var keyContent = keyMatch.Groups[2].Value;
var keyRawData = Convert.FromBase64String(keyContent);
var seq = Asn1Sequence.GetInstance(keyRawData);

if (seq.Count != 9)
{
throw new NotSupportedException("Invalid RSA Private Key ASN1 sequence.");
}

var keyStructure = RsaPrivateKeyStructure.GetInstance(seq);
var privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(new RsaPrivateCrtKeyParameters(keyStructure));
var key = PrivateKeyFactory.CreateKey(privateKeyInfo);

return key;
}

private MSX509.X509Certificate2 GetClientCertificate()
{
var certificate = ReadPemCert(this.dockerClientCertFile);
var key = ReadPemRsaPrivateKey(this.dockerClientKeyFile);

return GetCertificateWithKey(certificate, key);
}
}
}
25 changes: 16 additions & 9 deletions src/Testcontainers/Builders/TlsEndpointAuthenticationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ namespace DotNet.Testcontainers.Builders
using DotNet.Testcontainers.Configurations.Credentials;

/// <inheritdoc cref="IDockerRegistryAuthenticationProvider" />
internal sealed class TlsEndpointAuthenticationProvider : DockerEndpointAuthenticationProvider
internal class TlsEndpointAuthenticationProvider : DockerEndpointAuthenticationProvider
{
private const string DefaultUserDockerFolderName = ".docker";
private const string DefaultCaCertFileName = "ca.pem";
private static readonly Uri DefaultTlsDockerEndpoint = new Uri("tcp://localhost:2376");
private static readonly string DefaultCertPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), DefaultUserDockerFolderName);

private readonly Lazy<X509Certificate2> caCertificate;
private readonly Uri dockerEngine;
private readonly bool dockerTlsEnabled;

/// <summary>
Expand All @@ -34,20 +33,28 @@ public TlsEndpointAuthenticationProvider()
/// <param name="customConfigurations">A list of custom configurations.</param>
public TlsEndpointAuthenticationProvider(params ICustomConfiguration[] customConfigurations)
{
var dockerCertPath = customConfigurations
this.DockerCertPath = customConfigurations
.Select(customConfiguration => customConfiguration.GetDockerCertPath())
.FirstOrDefault(value => value != null) ?? DefaultCertPath;
var dockerCaCertFile = Path.Combine(dockerCertPath, DefaultCaCertFileName);

this.dockerEngine = customConfigurations
this.DockerEngine = customConfigurations
.Select(customConfiguration => customConfiguration.GetDockerHost())
.FirstOrDefault(value => value != null) ?? DefaultTlsDockerEndpoint;
this.dockerTlsEnabled = customConfigurations
.Select(customConfiguration => customConfiguration.GetDockerTls())
.Aggregate(false, (x, y) => x || y);
this.caCertificate = new Lazy<X509Certificate2>(() => new X509Certificate2(dockerCaCertFile));
this.caCertificate = new Lazy<X509Certificate2>(() => new X509Certificate2(Path.Combine(this.DockerCertPath, DefaultCaCertFileName)));
}

/// <summary>
/// Gets path to the docker certificate folder.
/// </summary>
protected string DockerCertPath { get; }

/// <summary>
/// Gets URI to the docker engine.
/// </summary>
protected Uri DockerEngine { get; }

/// <inheritdoc />
public override bool IsApplicable()
{
Expand All @@ -57,10 +64,10 @@ public override bool IsApplicable()
/// <inheritdoc />
public override IDockerEndpointAuthenticationConfiguration GetAuthConfig()
{
return new DockerEndpointAuthenticationConfiguration(this.dockerEngine, new TlsCredentials(this.ServerCertificateValidationCallback));
return new DockerEndpointAuthenticationConfiguration(this.DockerEngine, new TlsCredentials(this.ServerCertificateValidationCallback));
}

private bool ServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
protected bool ServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
switch (sslPolicyErrors)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Testcontainers/Clients/DockerSystemOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace DotNet.Testcontainers.Clients
using System;
using System.Threading;
using System.Threading.Tasks;
using Docker.DotNet.Models;
using DotNet.Testcontainers.Configurations;
using Microsoft.Extensions.Logging;

Expand All @@ -19,5 +20,10 @@ public async Task<bool> GetIsWindowsEngineEnabled(CancellationToken ct = default
return (await this.Docker.System.GetSystemInfoAsync(ct)
.ConfigureAwait(false)).OperatingSystem.Contains("Windows");
}

internal async Task<VersionResponse> GetVersion(CancellationToken ct = default)
{
return await this.Docker.System.GetVersionAsync(ct).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static class TestcontainersSettings
private static readonly IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig =
new IDockerEndpointAuthenticationProvider[]
{
new MTlsEndpointAuthenticationProvider(),
new TlsEndpointAuthenticationProvider(),
new EnvironmentEndpointAuthenticationProvider(),
new NpipeEndpointAuthenticationProvider(),
Expand Down
4 changes: 3 additions & 1 deletion src/Testcontainers/Testcontainers.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Microsoft.Build.CentralPackageVersions" Version="2.1.3" />
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1</TargetFrameworks>
Expand All @@ -9,8 +9,10 @@
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Docker.DotNet" />
<PackageReference Include="Docker.DotNet.X509" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Portable.BouncyCastle" />
<PackageReference Include="SharpZipLib" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace DotNet.Testcontainers.Tests.Fixtures.Builders
{
using System;
using System.IO;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Clients;
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

public class MTlsEndpointAuthenticationProviderFixture : IAsyncLifetime
{
private const string CertsDirectoryName = "certs";
private static readonly string ContainerCertDirectoryPath = Path.Combine("/", CertsDirectoryName);
private static readonly string HostCertDirectoryPath = Path.Combine(Path.GetTempPath(), CertsDirectoryName);

private readonly ITestcontainersContainer mTlsContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("docker:20.10-dind")
.WithPrivileged(true)
.WithEnvironment("DOCKER_CERT_PATH", ContainerCertDirectoryPath)
.WithEnvironment("DOCKER_TLS_CERTDIR", ContainerCertDirectoryPath)
.WithEnvironment("DOCKER_TLS", "1")
.WithEnvironment("DOCKER_TLS_VERIFY", "1")
.WithBindMount(HostCertDirectoryPath, ContainerCertDirectoryPath, AccessMode.ReadWrite)
.WithPortBinding(2376, true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(2376))
.Build();

internal DockerSystemOperations DockerSystemOperations { get; private set; }

public async Task InitializeAsync()
{
await this.mTlsContainer.StartAsync();
var propertiesFileConfiguration = new PropertiesFileConfiguration(
$"docker.host=tcp://localhost:{this.mTlsContainer.GetMappedPublicPort(2376)}",
"docker.tls=true",
$"docker.cert.path={Path.Combine(HostCertDirectoryPath, "client")}");
var mTlsEndpointAuthenticationProvider = new MTlsEndpointAuthenticationProvider(propertiesFileConfiguration);
this.DockerSystemOperations = new DockerSystemOperations(Guid.NewGuid(), mTlsEndpointAuthenticationProvider.GetAuthConfig(), NullLogger.Instance);
}

public Task DisposeAsync()
{
return this.mTlsContainer.DisposeAsync().AsTask();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace DotNet.Testcontainers.Tests.Unit
{
using System.Threading.Tasks;
using DotNet.Testcontainers.Tests.Fixtures.Builders;
using Xunit;

public class MTlsEndpointAuthenticationProviderTest : IClassFixture<MTlsEndpointAuthenticationProviderFixture>
{
private readonly MTlsEndpointAuthenticationProviderFixture fixture;

public MTlsEndpointAuthenticationProviderTest(MTlsEndpointAuthenticationProviderFixture fixture)
{
this.fixture = fixture;
}

[Fact]
public async Task MTlsEndpointAuthenticationProviderIsAbleProvideDockerEndpointConnectWithMTlsConfiguration()
{
var versionResponse = await this.fixture.DockerSystemOperations.GetVersion();

Assert.NotNull(versionResponse);
Assert.Contains(versionResponse.Components, x => x.Name.ToLowerInvariant() == "engine");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public AuthProviderTestData()
var defaultConfiguration = new PropertiesFileConfiguration(Array.Empty<string>());
var dockerHostConfiguration = new PropertiesFileConfiguration(new[] { $"docker.host={DockerHost}" });
var dockerTlsConfiguration = new PropertiesFileConfiguration(new[] { "docker.tls=true" });
var dockerMTlsConfiguration = new PropertiesFileConfiguration(new[] { "docker.tls.verify=true" });
this.Add(new object[] { new MTlsEndpointAuthenticationProvider(defaultConfiguration), false });
this.Add(new object[] { new MTlsEndpointAuthenticationProvider(dockerMTlsConfiguration), true });
this.Add(new object[] { new MTlsEndpointAuthenticationProvider(Array.Empty<ICustomConfiguration>()), false });
this.Add(new object[] { new MTlsEndpointAuthenticationProvider(defaultConfiguration, dockerMTlsConfiguration), true });
this.Add(new object[] { new TlsEndpointAuthenticationProvider(defaultConfiguration), false });
this.Add(new object[] { new TlsEndpointAuthenticationProvider(dockerTlsConfiguration), true });
this.Add(new object[] { new TlsEndpointAuthenticationProvider(Array.Empty<ICustomConfiguration>()), false });
Expand Down

0 comments on commit b787bfc

Please sign in to comment.