diff --git a/Directory.Build.props b/Directory.Build.props index 8eba195b4..ffca9e0cf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -21,10 +21,14 @@ https://github.com/testcontainers/testcontainers-dotnet - $(MSBuildThisFileDirectory)src/strongname.snk - true + + true + false embedded + + $(MSBuildThisFileDirectory)src/strongname.snk + CA1859,CA1861,CS0618,CS1591,xUnit1044,xUnit1045 @@ -41,9 +45,14 @@ - + <_Parameter1>Testcontainers.Tests, PublicKey=$([System.IO.File]::ReadAllText($(MSBuildThisFileDirectory)src/strongname.pub)) + + + <_Parameter1>Testcontainers.Tests + + diff --git a/docs/modules/postgres.md b/docs/modules/postgres.md index 376056cf3..ebae12332 100644 --- a/docs/modules/postgres.md +++ b/docs/modules/postgres.md @@ -32,4 +32,32 @@ The test example uses the following NuGet dependencies: To execute the tests, use the command `dotnet test` from a terminal. ---8<-- "docs/modules/_call_out_test_projects.txt" \ No newline at end of file +## Enable SSL/TLS + +The PostgreSQL module supports configuring server-side SSL. Provide paths to your CA certificate, server certificate, and server private key when building the container: + +```csharp +var postgreSqlContainer = new PostgreSqlBuilder() + .WithSSLSettings("/path/to/ca_cert.pem", + "/path/to/server.crt", + "/path/to/server.key") + .Build(); +await postgreSqlContainer.StartAsync(); +``` + +When connecting with Npgsql during tests, you can require SSL and (optionally) trust the test certificate: + +```csharp +var csb = new Npgsql.NpgsqlConnectionStringBuilder(postgreSqlContainer.GetConnectionString()) +{ + SslMode = Npgsql.SslMode.Require, + // For testing only; prefer proper CA validation in production. + TrustServerCertificate = true +}; +await using var connection = new Npgsql.NpgsqlConnection(csb.ConnectionString); +await connection.OpenAsync(); +``` + +For production scenarios, validate the server certificate against a trusted CA instead of using TrustServerCertificate. + +--8<-- "docs/modules/_call_out_test_projects.txt" diff --git a/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs b/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs index 73ec3a317..00f2f1456 100644 --- a/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs +++ b/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs @@ -2,7 +2,8 @@ namespace Testcontainers.PostgreSql; /// [PublicAPI] -public sealed class PostgreSqlBuilder : ContainerBuilder +public sealed class + PostgreSqlBuilder : ContainerBuilder { public const string PostgreSqlImage = "postgres:15.1"; @@ -14,6 +15,8 @@ public sealed class PostgreSqlBuilder : ContainerBuilder /// Initializes a new instance of the class. /// @@ -69,14 +72,122 @@ public PostgreSqlBuilder WithPassword(string password) .WithEnvironment("POSTGRES_PASSWORD", password); } + /// + /// Sets the PostgreSql SSL mode. + /// + /// The PostgreSql SSL mode. + /// A configured instance of . + public PostgreSqlBuilder WithSslMode(SslMode sslMode) + { + return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(sslMode: sslMode)) + .WithEnvironment("PGSSLMODE", sslMode.ToString().ToLowerInvariant()); + } + + /// + /// Sets the PostgreSql root certificate file. + /// + /// The path to the root certificate file. + /// A configured instance of . + public PostgreSqlBuilder WithRootCertificate(string rootCertFile) + { + return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(rootCertFile: rootCertFile)) + .WithBindMount(rootCertFile, Path.Combine(DefaultCertificatesDirectory, "root.crt"), AccessMode.ReadOnly) + .WithEnvironment("PGSSLROOTCERT", Path.Combine(DefaultCertificatesDirectory, "root.crt")); + } + + /// + /// Sets the PostgreSql client certificate and key files. + /// + /// The path to the client certificate file. + /// The path to the client key file. + /// A configured instance of . + public PostgreSqlBuilder WithClientCertificate(string clientCertFile, string clientKeyFile) + { + return Merge(DockerResourceConfiguration, + new PostgreSqlConfiguration(clientCertFile: clientCertFile, clientKeyFile: clientKeyFile)) + .WithBindMount(clientCertFile, Path.Combine(DefaultCertificatesDirectory, "postgresql.crt"), + AccessMode.ReadOnly) + .WithBindMount(clientKeyFile, Path.Combine(DefaultCertificatesDirectory, "postgresql.key"), + AccessMode.ReadOnly) + .WithEnvironment("PGSSLCERT", Path.Combine(DefaultCertificatesDirectory, "postgresql.crt")) + .WithEnvironment("PGSSLKEY", Path.Combine(DefaultCertificatesDirectory, "postgresql.key")); + } + + /// + /// Configures the PostgreSQL server to run with SSL using the provided CA certificate, server certificate and private key. + /// This enables server-side SSL configuration with client certificate authentication. + /// + /// The path to the CA certificate file. + /// The path to the server certificate file. + /// The path to the server private key file. + /// A configured instance of . + /// + /// This method configures PostgreSQL for server-side SSL with client certificate authentication. + /// It requires a custom PostgreSQL configuration file that enables SSL and sets the appropriate + /// certificate paths. The certificates are mounted into the container and PostgreSQL is configured + /// to use them for SSL connections. + /// + public PostgreSqlBuilder WithSSLSettings(string caCertFile, string serverCertFile, string serverKeyFile) + { + if (string.IsNullOrWhiteSpace(caCertFile)) + { + throw new ArgumentException("CA certificate file path cannot be null or empty.", nameof(caCertFile)); + } + + if (string.IsNullOrWhiteSpace(serverCertFile)) + { + throw new ArgumentException("Server certificate file path cannot be null or empty.", + nameof(serverCertFile)); + } + + if (string.IsNullOrWhiteSpace(serverKeyFile)) + { + throw new ArgumentException("Server key file path cannot be null or empty.", nameof(serverKeyFile)); + } + + const string sslConfigDir = "/tmp/testcontainers-dotnet/postgres"; + + var wrapperEntrypoint = @"#!/bin/sh +set -e +SSL_DIR=/tmp/testcontainers-dotnet/postgres +# Fix ownership and permissions for SSL key/cert before Postgres init runs +if [ -f ""$SSL_DIR/server.key"" ]; then + chown postgres:postgres ""$SSL_DIR/server.key"" || true + chmod 600 ""$SSL_DIR/server.key"" || true +fi +if [ -f ""$SSL_DIR/server.crt"" ]; then + chown postgres:postgres ""$SSL_DIR/server.crt"" || true +fi +if [ -f ""$SSL_DIR/ca_cert.pem"" ]; then + chown postgres:postgres ""$SSL_DIR/ca_cert.pem"" || true +fi +exec /usr/local/bin/docker-entrypoint.sh ""$@"" +"; + + return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration( + serverCertFile: serverCertFile, + serverKeyFile: serverKeyFile, + caCertFile: caCertFile)) + .WithResourceMapping(File.ReadAllBytes(caCertFile), $"{sslConfigDir}/ca_cert.pem", fileMode: Unix.FileMode644) + .WithResourceMapping(File.ReadAllBytes(serverCertFile), $"{sslConfigDir}/server.crt", fileMode: Unix.FileMode644) + .WithResourceMapping(File.ReadAllBytes(serverKeyFile), $"{sslConfigDir}/server.key", fileMode: Unix.FileMode700) + .WithResourceMapping(Encoding.UTF8.GetBytes(wrapperEntrypoint), "/usr/local/bin/docker-entrypoint-ssl.sh", fileMode: Unix.FileMode755) + .WithEntrypoint("/usr/local/bin/docker-entrypoint-ssl.sh") + .WithCommand("-c", "ssl=on") + .WithCommand("-c", $"ssl_ca_file={sslConfigDir}/ca_cert.pem") + .WithCommand("-c", $"ssl_cert_file={sslConfigDir}/server.crt") + .WithCommand("-c", $"ssl_key_file={sslConfigDir}/server.key"); + } + /// public override PostgreSqlContainer Build() { Validate(); - // By default, the base builder waits until the container is running. However, for PostgreSql, a more advanced waiting strategy is necessary that requires access to the configured database and username. - // If the user does not provide a custom waiting strategy, append the default PostgreSql waiting strategy. - var postgreSqlBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration))); + // Ensure PostgreSQL is actually ready to accept connections over TCP, not just that the container is running. + // Always append the pg_isready-based wait strategy by default so tests using the default fixture are stable. + var postgreSqlBuilder = + WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration))); return new PostgreSqlContainer(postgreSqlBuilder.DockerResourceConfiguration); } @@ -135,7 +246,11 @@ private sealed class WaitUntil : IWaitUntil public WaitUntil(PostgreSqlConfiguration configuration) { // Explicitly specify the host to ensure readiness only after the initdb scripts have executed, and the server is listening on TCP/IP. - _command = new List { "pg_isready", "--host", "localhost", "--dbname", configuration.Database, "--username", configuration.Username }; + _command = new List + { + "pg_isready", "--host", "localhost", "--dbname", configuration.Database, "--username", + configuration.Username + }; } /// @@ -154,7 +269,8 @@ public async Task UntilAsync(IContainer container) if (execResult.Stderr.Contains("pg_isready was not found")) { - throw new NotSupportedException($"The '{container.Image.FullName}' image does not contain: pg_isready. Please use 'postgres:9.3' onwards."); + throw new NotSupportedException( + $"The '{container.Image.FullName}' image does not contain: pg_isready. Please use 'postgres:9.3' onwards."); } return 0L.Equals(execResult.ExitCode); diff --git a/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs b/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs index 05873fe8a..bac9d9ec1 100644 --- a/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs +++ b/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs @@ -10,14 +10,35 @@ public sealed class PostgreSqlConfiguration : ContainerConfiguration /// The PostgreSql database. /// The PostgreSql username. /// The PostgreSql password. + /// The PostgreSql SSL mode. + /// The path to the PostgreSql root certificate file. + /// The path to the PostgreSql client certificate file. + /// The path to the PostgreSql client key file. + /// The path to the PostgreSql server certificate file. + /// The path to the PostgreSql server key file. + /// The path to the PostgreSql CA certificate file. public PostgreSqlConfiguration( string database = null, string username = null, - string password = null) + string password = null, + SslMode? sslMode = null, + string rootCertFile = null, + string clientCertFile = null, + string clientKeyFile = null, + string serverCertFile = null, + string serverKeyFile = null, + string caCertFile = null) { Database = database; Username = username; Password = password; + SslMode = sslMode; + RootCertFile = rootCertFile; + ClientCertFile = clientCertFile; + ClientKeyFile = clientKeyFile; + ServerCertFile = serverCertFile; + ServerKeyFile = serverKeyFile; + CaCertFile = caCertFile; } /// @@ -61,6 +82,13 @@ public PostgreSqlConfiguration(PostgreSqlConfiguration oldValue, PostgreSqlConfi Database = BuildConfiguration.Combine(oldValue.Database, newValue.Database); Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + SslMode = BuildConfiguration.Combine(oldValue.SslMode, newValue.SslMode); + RootCertFile = BuildConfiguration.Combine(oldValue.RootCertFile, newValue.RootCertFile); + ClientCertFile = BuildConfiguration.Combine(oldValue.ClientCertFile, newValue.ClientCertFile); + ClientKeyFile = BuildConfiguration.Combine(oldValue.ClientKeyFile, newValue.ClientKeyFile); + ServerCertFile = BuildConfiguration.Combine(oldValue.ServerCertFile, newValue.ServerCertFile); + ServerKeyFile = BuildConfiguration.Combine(oldValue.ServerKeyFile, newValue.ServerKeyFile); + CaCertFile = BuildConfiguration.Combine(oldValue.CaCertFile, newValue.CaCertFile); } /// @@ -77,4 +105,39 @@ public PostgreSqlConfiguration(PostgreSqlConfiguration oldValue, PostgreSqlConfi /// Gets the PostgreSql password. /// public string Password { get; } + + /// + /// Gets the PostgreSql SSL mode. + /// + public SslMode? SslMode { get; } + + /// + /// Gets the path to the PostgreSql root certificate file. + /// + public string RootCertFile { get; } + + /// + /// Gets the path to the PostgreSql client certificate file. + /// + public string ClientCertFile { get; } + + /// + /// Gets the path to the PostgreSql client key file. + /// + public string ClientKeyFile { get; } + + /// + /// Gets the path to the PostgreSql server certificate file. + /// + public string ServerCertFile { get; } + + /// + /// Gets the path to the PostgreSql server key file. + /// + public string ServerKeyFile { get; } + + /// + /// Gets the path to the PostgreSql CA certificate file. + /// + public string CaCertFile { get; } } \ No newline at end of file diff --git a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs index 1981730dd..812617ccc 100644 --- a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs +++ b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs @@ -44,7 +44,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, fileMode: Unix.FileMode644, ct: ct) .ConfigureAwait(false); - return await ExecAsync(new[] { "psql", "--username", _configuration.Username, "--dbname", _configuration.Database, "--file", scriptFilePath }, ct) + return await ExecAsync(new[] { "psql", "--host", "localhost", "--username", _configuration.Username, "--dbname", _configuration.Database, "--file", scriptFilePath }, ct) .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Testcontainers.PostgreSql/SslMode.cs b/src/Testcontainers.PostgreSql/SslMode.cs new file mode 100644 index 000000000..8abcc4947 --- /dev/null +++ b/src/Testcontainers.PostgreSql/SslMode.cs @@ -0,0 +1,27 @@ +namespace Testcontainers.PostgreSql; + +/// +/// Represents the SSL mode for PostgreSQL connections. +/// +public enum SslMode +{ + /// + /// SSL is disabled. + /// + Disable, + + /// + /// SSL is required. + /// + Require, + + /// + /// SSL is required, and the server certificate is verified against the root certificate. + /// + VerifyCa, + + /// + /// SSL is required, and the server certificate is verified against the root certificate and the common name. + /// + VerifyFull +} \ No newline at end of file diff --git a/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs new file mode 100644 index 000000000..bcc3828eb --- /dev/null +++ b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs @@ -0,0 +1,269 @@ +#nullable enable +using System.IO; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Testcontainers.PostgreSql.Tests; + +[UsedImplicitly] +public sealed class PostgreSqlSslConfigTest +{ + private const string CaCertFileName = "ca_cert.pem"; + private const string ServerCertFileName = "server.crt"; + private const string ServerKeyFileName = "server.key"; + private const string ClientCertFileName = "client.crt"; + private const string ClientKeyFileName = "client.key"; + + private readonly string _tempDir; + private readonly string _caCertPath; + private readonly string _serverCertPath; + private readonly string _serverKeyPath; + + private PostgreSqlContainer? _postgreSqlContainer; + + public PostgreSqlSslConfigTest() + { + _tempDir = Path.Combine(Path.GetTempPath(), "testcontainers-ssl-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_tempDir); + + _caCertPath = Path.Combine(_tempDir, CaCertFileName); + _serverCertPath = Path.Combine(_tempDir, ServerCertFileName); + _serverKeyPath = Path.Combine(_tempDir, ServerKeyFileName); + Path.Combine(_tempDir, ClientCertFileName); + Path.Combine(_tempDir, ClientKeyFileName); + } + + private async Task EnsureContainerStartedAsync() + { + if (_postgreSqlContainer != null) + { + return; + } + + // Generate SSL certificates for testing + await GenerateSSLCertificates(); + + // Create and start the PostgreSQL container with SSL configuration + _postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("testuser") + .WithPassword("testpass123") + .WithSSLSettings(_caCertPath, _serverCertPath, _serverKeyPath) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilInternalTcpPortIsAvailable(PostgreSqlBuilder.PostgreSqlPort) + .UntilMessageIsLogged("database system is ready to accept connections")) + .Build(); + + await _postgreSqlContainer.StartAsync(); + } + + private void Cleanup() + { + try + { + _postgreSqlContainer?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // ignore + } + + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + } + + [Fact] + public async Task PostgreSqlContainerCanStartWithSSLSettings() + { + // Given + await EnsureContainerStartedAsync(); + Assert.NotNull(_postgreSqlContainer); + + // When + var connectionString = _postgreSqlContainer!.GetConnectionString(); + + // Then + Assert.NotEmpty(connectionString); + Assert.Contains("testdb", connectionString); + Assert.Contains("testuser", connectionString); + } + + [Fact] + public async Task PostgreSqlContainerCanConnectWithSSL() + { + // Given + await EnsureContainerStartedAsync(); + Assert.NotNull(_postgreSqlContainer); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer!.GetConnectionString()) + { + SslMode = Npgsql.SslMode.Require, + TrustServerCertificate = true // For testing only - in production use proper certificate validation + }; + + // When + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + // Then + Assert.Equal(ConnectionState.Open, connection.State); + + // Verify SSL is being used + await using var command = + new NpgsqlCommand("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();", connection); + var sslIsUsed = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken); + Assert.True(sslIsUsed is bool b && b, "SSL should be enabled for the connection"); + } + + [Fact] + public async Task PostgreSqlContainerWithSSLCanExecuteQueries() + { + // Given + await EnsureContainerStartedAsync(); + Assert.NotNull(_postgreSqlContainer); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer!.GetConnectionString()) + { + SslMode = Npgsql.SslMode.Require, + TrustServerCertificate = true + }; + + // When + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + await using var command = + new NpgsqlCommand("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name VARCHAR(100));", connection); + await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + await using var insertCommand = + new NpgsqlCommand("INSERT INTO test_table (name) VALUES ('Test SSL Connection');", connection); + await insertCommand.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + + await using var selectCommand = new NpgsqlCommand("SELECT COUNT(*) FROM test_table;", connection); + var count = await selectCommand.ExecuteScalarAsync(TestContext.Current.CancellationToken); + + // Then + Assert.Equal(1L, count); + } + + [Fact] + public void WithSSLCSettingsThrowsArgumentExceptionForEmptyCaCert() + { + // Given, When, Then + var exception = Assert.Throws(() => + new PostgreSqlBuilder().WithSSLSettings("", _serverCertPath, _serverKeyPath)); + + Assert.Equal("caCertFile", exception.ParamName); + Assert.Contains("CA certificate file path cannot be null or empty", exception.Message); + } + + [Fact] + public void WithSSLSettingsThrowsArgumentExceptionForEmptyServerCert() + { + // Given, When, Then + var exception = Assert.Throws(() => + new PostgreSqlBuilder().WithSSLSettings(_caCertPath, "", _serverKeyPath)); + + Assert.Equal("serverCertFile", exception.ParamName); + Assert.Contains("Server certificate file path cannot be null or empty", exception.Message); + } + + [Fact] + public void WithSSLSettingsThrowsArgumentExceptionForEmptyServerKey() + { + // Given, When, Then + var exception = Assert.Throws(() => + new PostgreSqlBuilder().WithSSLSettings(_caCertPath, _serverCertPath, "")); + + Assert.Equal("serverKeyFile", exception.ParamName); + Assert.Contains("Server key file path cannot be null or empty", exception.Message); + } + + private async Task GenerateSSLCertificates() + { + // Create a simple RSA key pair for testing + using var rsa = RSA.Create(2048); + + // Create CA certificate + var caCertRequest = new CertificateRequest( + "CN=Test CA, O=Testcontainers", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + caCertRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + caCertRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + + var caNotBefore = DateTimeOffset.UtcNow.AddDays(-1); + var caNotAfter = DateTimeOffset.UtcNow.AddDays(365); + using var caCert = caCertRequest.CreateSelfSigned(caNotBefore, caNotAfter); + + // Save CA certificate + await File.WriteAllTextAsync(_caCertPath, + "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(caCert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"); + + // Create server certificate + using var serverRsa = RSA.Create(2048); + var serverCertRequest = new CertificateRequest( + "CN=localhost, O=Testcontainers", + serverRsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + serverCertRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + true)); + + serverCertRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, // Server Authentication + true)); + + // Add Subject Alternative Names + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(IPAddress.Loopback); + serverCertRequest.CertificateExtensions.Add(sanBuilder.Build()); + + var serverNotBefore = caNotBefore.AddMinutes(1) > DateTimeOffset.UtcNow.AddDays(-1) + ? caNotBefore.AddMinutes(1) + : DateTimeOffset.UtcNow.AddDays(-1); + var serverNotAfter = caNotAfter.AddMinutes(-1); + using var serverCert = serverCertRequest.Create( + caCert, + serverNotBefore, + serverNotAfter, + new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); + + // Save server certificate + await File.WriteAllTextAsync(_serverCertPath, + "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(serverCert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"); + + // Save server private key + await File.WriteAllTextAsync(_serverKeyPath, + "-----BEGIN PRIVATE KEY-----\n" + + Convert.ToBase64String(serverRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END PRIVATE KEY-----\n"); + + // Set appropriate permissions for private key + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(_serverKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } +} \ No newline at end of file diff --git a/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs new file mode 100644 index 000000000..223bd7a00 --- /dev/null +++ b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs @@ -0,0 +1,141 @@ +using System; +using System.Data; +using System.IO; +using System.Threading.Tasks; +using DotNet.Testcontainers.Configurations; +using Npgsql; +using Xunit; +using Xunit.Internal; + +namespace Testcontainers.PostgreSql.Tests; + +[UsedImplicitly] +public sealed class PostgreSqlSslTest : IAsyncLifetime +{ + private readonly string _tempDir; + private readonly string _caCertPath; + private readonly string _serverCertPath; + private readonly string _serverKeyPath; + + private readonly PostgreSqlContainer _postgreSqlContainer; + + public PostgreSqlSslTest() + { + _tempDir = Path.Combine(Path.GetTempPath(), "testcontainers-ssl-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + Directory.CreateDirectory(_tempDir); + + _caCertPath = Path.Combine(_tempDir, "ca_cert.pem"); + _serverCertPath = Path.Combine(_tempDir, "server.crt"); + _serverKeyPath = Path.Combine(_tempDir, "server.key"); + + // Generate simple CA and server certificates for the test + GenerateCertificatesAsync().GetAwaiter().GetResult(); + + _postgreSqlContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("testdb") + .WithUsername("testuser") + .WithPassword("testpass123") + .WithSSLSettings(_caCertPath, _serverCertPath, _serverKeyPath) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilInternalTcpPortIsAvailable(PostgreSqlBuilder.PostgreSqlPort) + .UntilMessageIsLogged("database system is ready to accept connections")) + .Build(); + } + + [Fact] + public async Task PostgreSqlContainerCanConnectWithSsl() + { + // Given + await _postgreSqlContainer.StartAsync(TestContext.Current.CancellationToken); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer.GetConnectionString()) + { + SslMode = Npgsql.SslMode.Require, + TrustServerCertificate = true + }; + + // When + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(TestContext.Current.CancellationToken); + + // Then + Assert.Equal(ConnectionState.Open, connection.State); + } + + public ValueTask InitializeAsync() + { + // no-op, container started within test + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + try + { + if (_postgreSqlContainer != null) + { + await _postgreSqlContainer.DisposeAsync(); + } + } + finally + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + } + + private static async Task WritePemAsync(string path, byte[] derBytes, string begin, string end) + { + await File.WriteAllTextAsync(path, $"{begin}\n{Convert.ToBase64String(derBytes, Base64FormattingOptions.InsertLineBreaks)}\n{end}\n"); + } + + private async Task GenerateCertificatesAsync() + { + using var caRsa = System.Security.Cryptography.RSA.Create(2048); + var caReq = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=Test CA, O=Testcontainers", + caRsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + caReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true)); + caReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509KeyUsageExtension( + System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.KeyCertSign | + System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.CrlSign, true)); + var caNotBefore = DateTimeOffset.UtcNow.AddDays(-1); + var caNotAfter = DateTimeOffset.UtcNow.AddDays(365); + using var caCert = caReq.CreateSelfSigned(caNotBefore, caNotAfter); + await WritePemAsync(_caCertPath, caCert.RawData, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + + using var serverRsa = System.Security.Cryptography.RSA.Create(2048); + var serverReq = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=localhost, O=Testcontainers", + serverRsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + serverReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509KeyUsageExtension( + System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.DigitalSignature | + System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.KeyEncipherment, true)); + var san = new System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + san.AddIpAddress(System.Net.IPAddress.Loopback); + serverReq.CertificateExtensions.Add(san.Build()); + var serverNotBefore = caNotBefore.AddMinutes(1); + var serverNotAfter = caNotAfter.AddMinutes(-1); + using var serverCert = serverReq.Create( + caCert, + serverNotBefore, + serverNotAfter, + new ReadOnlySpan(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16))); + await WritePemAsync(_serverCertPath, serverCert.RawData, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----"); + await File.WriteAllTextAsync(_serverKeyPath, "-----BEGIN PRIVATE KEY-----\n" + + Convert.ToBase64String(serverRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END PRIVATE KEY-----\n"); + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(_serverKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } +} \ No newline at end of file