diff --git a/examples/PostgreSqlSslConfigDemo/PostgreSqlSSLConfigExample.cs b/examples/PostgreSqlSslConfigDemo/PostgreSqlSSLConfigExample.cs new file mode 100644 index 000000000..27cf69624 --- /dev/null +++ b/examples/PostgreSqlSslConfigDemo/PostgreSqlSSLConfigExample.cs @@ -0,0 +1,330 @@ +namespace Testcontainers.PostgreSql.Examples; + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Npgsql; + +/// +/// Example demonstrating how to use PostgreSQL with SSL configuration using client certificate authentication. +/// This example shows how to set up a PostgreSQL container with server-side SSL certificates +/// and connect to it using client certificates for mutual TLS authentication. +/// +public static class PostgreSqlSSLConfigExample +{ + /// + /// Demonstrates creating a PostgreSQL container with SSL configuration and client certificate authentication. + /// + public static async Task RunExample() + { + // Create temporary directory for SSL certificates + var tempDir = Path.Combine(Path.GetTempPath(), "testcontainers-ssl-example"); + Directory.CreateDirectory(tempDir); + + try + { + // Generate SSL certificates for the example + var (caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath) = + await GenerateSSLCertificates(tempDir); + + Console.WriteLine("Generated SSL certificates for PostgreSQL server configuration:"); + Console.WriteLine($" CA Certificate: {caCertPath}"); + Console.WriteLine($" Server Certificate: {serverCertPath}"); + Console.WriteLine($" Server Private Key: {serverKeyPath}"); + Console.WriteLine(); + + // Create PostgreSQL container with SSL configuration + await using var container = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("example_db") + .WithUsername("ssl_user") + .WithPassword("secure_password123") + .WithSSLSettings(caCertPath, serverCertPath, serverKeyPath) + .Build(); + + Console.WriteLine("Starting PostgreSQL container with SSL configuration..."); + await container.StartAsync(); + Console.WriteLine("PostgreSQL container started successfully with SSL enabled!"); + Console.WriteLine(); + + // Example 1: Connect with SSL required but without client certificate + await ConnectWithSSLOnly(container); + + // Example 2: Connect with client certificate authentication + await ConnectWithClientCertificate(container, caCertPath, clientCertPath, clientKeyPath); + + // Example 3: Demonstrate SSL connection properties + await DemonstrateSSLProperties(container); + + Console.WriteLine("SSL configuration example completed successfully!"); + } + finally + { + // Clean up temporary certificates + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + Console.WriteLine($"Cleaned up temporary directory: {tempDir}"); + } + } + } + + /// + /// Connects to PostgreSQL with SSL required but without client certificate. + /// + private static async Task ConnectWithSSLOnly(PostgreSqlContainer container) + { + Console.WriteLine("Example 1: Connecting with SSL required (no client certificate)"); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + SslMode = SslMode.Require, + TrustServerCertificate = true // For demo only - use proper certificate validation in production + }; + + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(); + + Console.WriteLine($" ✓ Connected successfully with SSL"); + + // Ensure sslinfo extension is available for SSL inspection functions + await using (var enableExt = new NpgsqlCommand("CREATE EXTENSION IF NOT EXISTS sslinfo;", connection)) + { + await enableExt.ExecuteNonQueryAsync(); + } + + // Verify SSL is active + await using var command = new NpgsqlCommand("SELECT ssl_is_used();", connection); + var sslIsUsed = await command.ExecuteScalarAsync(); + Console.WriteLine($" ✓ SSL is active: {sslIsUsed}"); + Console.WriteLine(); + } + + /// + /// Connects to PostgreSQL using client certificate authentication. + /// + private static async Task ConnectWithClientCertificate(PostgreSqlContainer container, + string caCertPath, string clientCertPath, string clientKeyPath) + { + Console.WriteLine("Example 2: Connecting with client certificate authentication"); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + // Validate server certificate against our generated CA (no hostname verification to avoid IP/hostname mismatch) + SslMode = SslMode.VerifyCA, + RootCertificate = caCertPath, + ClientCertificate = clientCertPath, + ClientCertificateKey = clientKeyPath + }; + + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(); + + Console.WriteLine($" ✓ Connected successfully with client certificate"); + + // Create a test table and insert data + await using var createCommand = new NpgsqlCommand( + "CREATE TABLE IF NOT EXISTS ssl_test (id SERIAL PRIMARY KEY, message TEXT, created_at TIMESTAMP DEFAULT NOW());", + connection); + await createCommand.ExecuteNonQueryAsync(); + + await using var insertCommand = new NpgsqlCommand( + "INSERT INTO ssl_test (message) VALUES (@message) RETURNING id;", + connection); + insertCommand.Parameters.AddWithValue("@message", "SSL connection with client certificate successful!"); + + var insertedId = await insertCommand.ExecuteScalarAsync(); + Console.WriteLine($" ✓ Inserted record with ID: {insertedId}"); + Console.WriteLine(); + } + + /// + /// Demonstrates various SSL connection properties and queries. + /// + private static async Task DemonstrateSSLProperties(PostgreSqlContainer container) + { + Console.WriteLine("Example 3: Demonstrating SSL connection properties"); + + var connectionStringBuilder = new NpgsqlConnectionStringBuilder(container.GetConnectionString()) + { + SslMode = SslMode.Require, + TrustServerCertificate = true + }; + + await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString); + await connection.OpenAsync(); + + // Ensure sslinfo extension is available for SSL inspection functions + await using (var enableExt = new NpgsqlCommand("CREATE EXTENSION IF NOT EXISTS sslinfo;", connection)) + { + await enableExt.ExecuteNonQueryAsync(); + } + + // Query SSL-related information + var sslQueries = new[] + { + ("SSL Version", "SELECT ssl_version();"), + ("SSL Cipher", "SELECT ssl_cipher();"), + ("SSL Client Certificate Present", "SELECT CASE WHEN ssl_client_cert_present() THEN 'Yes' ELSE 'No' END;"), + ("SSL Client Serial Number", "SELECT COALESCE(ssl_client_serial(), 'Not Available');") + }; + + foreach (var (description, query) in sslQueries) + { + try + { + await using var command = new NpgsqlCommand(query, connection); + var result = await command.ExecuteScalarAsync(); + Console.WriteLine($" {description}: {result}"); + } + catch (Exception ex) + { + Console.WriteLine($" {description}: Error - {ex.Message}"); + } + } + Console.WriteLine(); + } + + /// + /// Generates SSL certificates for testing purposes. + /// In production, use proper certificates from a trusted CA. + /// + private static async Task<(string CaCert, string ServerCert, string ServerKey, string ClientCert, string ClientKey)> + GenerateSSLCertificates(string outputDir) + { + // Create CA certificate + using var caRsa = RSA.Create(2048); + var caCertRequest = new CertificateRequest( + "CN=Test CA, O=Testcontainers Example", + caRsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + caCertRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + caCertRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, + true)); + + using var caCert = caCertRequest.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + + var caCertPath = Path.Combine(outputDir, "ca_cert.pem"); + 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 Example", + 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)); + + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName("localhost"); + sanBuilder.AddIpAddress(System.Net.IPAddress.Loopback); + serverCertRequest.CertificateExtensions.Add(sanBuilder.Build()); + + using var serverCert = serverCertRequest.Create( + caCert, + caCert.NotBefore, + caCert.NotAfter.AddSeconds(-5), + new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); + + var serverCertPath = Path.Combine(outputDir, "server.crt"); + await File.WriteAllTextAsync(serverCertPath, + "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(serverCert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"); + + var serverKeyPath = Path.Combine(outputDir, "server.key"); + await File.WriteAllTextAsync(serverKeyPath, + "-----BEGIN PRIVATE KEY-----\n" + + Convert.ToBase64String(serverRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END PRIVATE KEY-----\n"); + + // Create client certificate + using var clientRsa = RSA.Create(2048); + var clientCertRequest = new CertificateRequest( + "CN=testcontainers-client, O=Testcontainers Example", + clientRsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + clientCertRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + true)); + + clientCertRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") }, // Client Authentication + true)); + + using var clientCert = clientCertRequest.Create( + caCert, + caCert.NotBefore, + caCert.NotAfter.AddSeconds(-5), + new ReadOnlySpan(RandomNumberGenerator.GetBytes(16))); + + var clientCertPath = Path.Combine(outputDir, "client.crt"); + await File.WriteAllTextAsync(clientCertPath, + "-----BEGIN CERTIFICATE-----\n" + + Convert.ToBase64String(clientCert.RawData, Base64FormattingOptions.InsertLineBreaks) + + "\n-----END CERTIFICATE-----\n"); + + var clientKeyPath = Path.Combine(outputDir, "client.key"); + await File.WriteAllTextAsync(clientKeyPath, + "-----BEGIN PRIVATE KEY-----\n" + + Convert.ToBase64String(clientRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) + + "\n-----END PRIVATE KEY-----\n"); + + // Set appropriate file permissions for private keys + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(serverKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.SetUnixFileMode(clientKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + return (caCertPath, serverCertPath, serverKeyPath, clientCertPath, clientKeyPath); + } + + /// + /// Entry point for the example. + /// + public static async Task Main(string[] args) + { + Console.WriteLine("PostgreSQL SSL Configuration Example"); + Console.WriteLine("====================================="); + Console.WriteLine(); + + try + { + await RunExample(); + } + catch (Exception ex) + { + Console.WriteLine($"Example failed with error: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + Environment.Exit(1); + } + } +} diff --git a/examples/PostgreSqlSslConfigDemo/PostgreSqlSslConfigDemo.csproj b/examples/PostgreSqlSslConfigDemo/PostgreSqlSslConfigDemo.csproj new file mode 100644 index 000000000..1d913f913 --- /dev/null +++ b/examples/PostgreSqlSslConfigDemo/PostgreSqlSslConfigDemo.csproj @@ -0,0 +1,17 @@ + + + Exe + net8.0 + enable + enable + Program + + + + + + + + + + diff --git a/examples/PostgreSqlSslConfigDemo/Program.cs b/examples/PostgreSqlSslConfigDemo/Program.cs new file mode 100644 index 000000000..915fbec2a --- /dev/null +++ b/examples/PostgreSqlSslConfigDemo/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; +using Testcontainers.PostgreSql.Examples; + +internal class Program +{ + public static async Task Main(string[] args) + { + Console.WriteLine("=== Testcontainers PostgreSQL WithSSLSettings demo ===\n"); + + try + { + await PostgreSqlSSLConfigExample.RunExample(); + Console.WriteLine("\nDemo completed successfully."); + } + catch (Exception ex) + { + Console.Error.WriteLine("\nDemo failed: " + ex); + Environment.ExitCode = -1; + } + } +} diff --git a/examples/PostgreSqlSslConfigDemo/README.md b/examples/PostgreSqlSslConfigDemo/README.md new file mode 100644 index 000000000..7403ba791 --- /dev/null +++ b/examples/PostgreSqlSslConfigDemo/README.md @@ -0,0 +1,27 @@ +# PostgreSQL WithSSLSettings demo (Testcontainers for .NET) + +This small console project demonstrates how to run a PostgreSQL container configured for SSL/TLS using Testcontainers' `WithSSLSettings` API and how to connect to it using Npgsql. + +What it does: +- Generates a temporary CA, server, and client certificates on-the-fly (for demo purposes only). +- Starts a `postgres:16-alpine` container with server-side SSL enabled via `WithSSLSettings(ca, serverCert, serverKey)`. +- Shows two connection scenarios: + 1) SSL required (server-auth only; trusting the server cert for demo). + 2) Mutual TLS (client certificate authentication) using the generated client cert/key. +- Runs a simple SQL command and prints SSL-related metadata. + +How to run +1. Ensure Docker is installed and running. +2. From the repository root, run: + + ```bash + cd examples/PostgreSqlSslConfigDemo + dotnet run -c Release + ``` + +You should see output indicating the container starts with SSL and that both SSL-only and client-certificate connections succeed. Temporary certificates will be created in a temp folder and deleted automatically after the run. + +Notes +- The example links to the shared implementation file `examples/PostgreSqlSSLConfigExample.cs` to avoid duplication. +- This demo targets .NET 8.0 and uses the local `Testcontainers.PostgreSql` project reference from `src/` so you can test changes to `WithSSLSettings` live. +- Do not use `TrustServerCertificate = true` in production; it is included here only for demonstration. 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