diff --git a/docs/modules/postgresql.md b/docs/modules/postgresql.md index 2419fa665..7eac5c945 100644 --- a/docs/modules/postgresql.md +++ b/docs/modules/postgresql.md @@ -41,6 +41,15 @@ Choose an image from the [container registry](https://hub.docker.com/_/postgres) [](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSetUsername +### With SSL + +!!! note + `withSSL()` / `withSSLCert()` expect certificate/key files that already exist. They do not generate key material. + + +[](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSslConnect + + ### Snapshots !!! warning diff --git a/packages/modules/postgresql/src/pgvector-container.test.ts b/packages/modules/postgresql/src/pgvector-container.test.ts index eaf79f210..2b48b58ab 100644 --- a/packages/modules/postgresql/src/pgvector-container.test.ts +++ b/packages/modules/postgresql/src/pgvector-container.test.ts @@ -1,8 +1,11 @@ +import path from "node:path"; import { Client } from "pg"; import { getImage } from "../../../testcontainers/src/utils/test-helper"; import { PostgreSqlContainer } from "./postgresql-container"; const IMAGE = getImage(__dirname); +const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt"); +const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key"); describe("PgvectorContainer", { timeout: 180_000 }, () => { it("should work", async () => { @@ -41,4 +44,25 @@ describe("PgvectorContainer", { timeout: 180_000 }, () => { await client.end(); }); + + it("should connect with SSL", async () => { + await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + ssl: { + rejectUnauthorized: false, + }, + }); + await client.connect(); + + const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()"); + expect(result.rows[0]).toEqual({ ssl: true }); + + await client.end(); + }); }); diff --git a/packages/modules/postgresql/src/postgis-container.test.ts b/packages/modules/postgresql/src/postgis-container.test.ts index bbee13e98..e5e3ebcd4 100644 --- a/packages/modules/postgresql/src/postgis-container.test.ts +++ b/packages/modules/postgresql/src/postgis-container.test.ts @@ -1,8 +1,11 @@ +import path from "node:path"; import { Client } from "pg"; import { getImage } from "../../../testcontainers/src/utils/test-helper"; import { PostgreSqlContainer } from "./postgresql-container"; const IMAGE = getImage(__dirname); +const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt"); +const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key"); describe("PostgisContainer", { timeout: 180_000 }, () => { it("should work", async () => { @@ -41,4 +44,25 @@ describe("PostgisContainer", { timeout: 180_000 }, () => { await client.end(); }); + + it("should connect with SSL", async () => { + await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + ssl: { + rejectUnauthorized: false, + }, + }); + await client.connect(); + + const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()"); + expect(result.rows[0]).toEqual({ ssl: true }); + + await client.end(); + }); }); diff --git a/packages/modules/postgresql/src/postgresql-container.test.ts b/packages/modules/postgresql/src/postgresql-container.test.ts index 04a43db7c..ee1338a16 100644 --- a/packages/modules/postgresql/src/postgresql-container.test.ts +++ b/packages/modules/postgresql/src/postgresql-container.test.ts @@ -1,8 +1,12 @@ +import path from "node:path"; import { Client } from "pg"; import { getImage } from "../../../testcontainers/src/utils/test-helper"; import { PostgreSqlContainer } from "./postgresql-container"; const IMAGE = getImage(__dirname); +const SSL_CA_CERT = path.resolve(__dirname, "test-certs/ca.crt"); +const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt"); +const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key"); describe("PostgreSqlContainer", { timeout: 180_000 }, () => { it("should connect and return a query result", async () => { @@ -110,4 +114,39 @@ describe("PostgreSqlContainer", { timeout: 180_000 }, () => { await expect(() => container.start()).rejects.toThrow(); }); + + it("should connect with SSL", async () => { + // pgSslConnect { + await using container = await new PostgreSqlContainer(IMAGE) + .withSSLCert(SSL_CA_CERT, SSL_SERVER_CERT, SSL_SERVER_KEY) + .start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + ssl: { + rejectUnauthorized: false, + }, + }); + await client.connect(); + + const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()"); + expect(result.rows[0]).toEqual({ ssl: true }); + + await client.end(); + // } + }); + + it("should validate SSL certificate paths", () => { + const container = new PostgreSqlContainer(IMAGE); + + expect(() => container.withSSL("", "server.key")).toThrow("SSL certificate file should not be empty."); + expect(() => container.withSSL("server.crt", "")).toThrow("SSL key file should not be empty."); + expect(() => container.withSSLCert("", "server.crt", "server.key")).toThrow( + "SSL CA certificate file should not be empty." + ); + }); }); diff --git a/packages/modules/postgresql/src/postgresql-container.ts b/packages/modules/postgresql/src/postgresql-container.ts index cfe0c8c69..d477a7f4c 100755 --- a/packages/modules/postgresql/src/postgresql-container.ts +++ b/packages/modules/postgresql/src/postgresql-container.ts @@ -1,11 +1,41 @@ import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; const POSTGRES_PORT = 5432; +const POSTGRES_SSL_PATH = "/tmp/testcontainers-node/postgres"; +const POSTGRES_SSL_CA_CERT_PATH = `${POSTGRES_SSL_PATH}/ca_cert.pem`; +const POSTGRES_SSL_CERT_PATH = `${POSTGRES_SSL_PATH}/server.crt`; +const POSTGRES_SSL_KEY_PATH = `${POSTGRES_SSL_PATH}/server.key`; +const POSTGRES_SSL_ENTRYPOINT_PATH = "/usr/local/bin/docker-entrypoint-ssl.sh"; +const POSTGRES_SSL_ENTRYPOINT_CONTENT = `#!/bin/sh +set -eu + +puid="$(id -u postgres)" +pgid="$(id -g postgres)" + +if [ -z "$puid" ] || [ -z "$pgid" ]; then + echo "Unable to determine postgres uid/gid for SSL key material ownership" + exit 1 +fi + +for file in "${POSTGRES_SSL_CA_CERT_PATH}" "${POSTGRES_SSL_CERT_PATH}" "${POSTGRES_SSL_KEY_PATH}"; do + if [ -f "$file" ]; then + chown "$puid:$pgid" "$file" + chmod 600 "$file" + fi +done + +exec /usr/local/bin/docker-entrypoint.sh "$@" +`; + +type PostgreSqlSslConfig = { + caCertPath?: string; +}; export class PostgreSqlContainer extends GenericContainer { private database = "test"; private username = "test"; private password = "test"; + private sslConfig?: PostgreSqlSslConfig; constructor(image: string) { super(image); @@ -29,12 +59,63 @@ export class PostgreSqlContainer extends GenericContainer { return this; } + public withSSL(certFile: string, keyFile: string, caCertFile?: string): this { + if (!certFile) throw new Error("SSL certificate file should not be empty."); + if (!keyFile) throw new Error("SSL key file should not be empty."); + if (caCertFile !== undefined && !caCertFile) throw new Error("SSL CA certificate file should not be empty."); + + const filesToCopy = [ + { source: certFile, target: POSTGRES_SSL_CERT_PATH, mode: 0o600 }, + { source: keyFile, target: POSTGRES_SSL_KEY_PATH, mode: 0o600 }, + ]; + + if (caCertFile) { + filesToCopy.push({ source: caCertFile, target: POSTGRES_SSL_CA_CERT_PATH, mode: 0o600 }); + } + + this.sslConfig = { + caCertPath: caCertFile ? POSTGRES_SSL_CA_CERT_PATH : undefined, + }; + + this.withCopyFilesToContainer(filesToCopy) + .withCopyContentToContainer([ + { + content: POSTGRES_SSL_ENTRYPOINT_CONTENT, + target: POSTGRES_SSL_ENTRYPOINT_PATH, + mode: 0o700, + }, + ]) + .withEntrypoint(["sh", POSTGRES_SSL_ENTRYPOINT_PATH]); + + return this; + } + + public withSSLCert(caCertFile: string, certFile: string, keyFile: string): this { + if (!caCertFile) throw new Error("SSL CA certificate file should not be empty."); + return this.withSSL(certFile, keyFile, caCertFile); + } + public override async start(): Promise { this.withEnvironment({ POSTGRES_DB: this.database, POSTGRES_USER: this.username, POSTGRES_PASSWORD: this.password, }); + if (this.sslConfig) { + const command = this.createOpts.Cmd; + if (!command || command[0] === "postgres") { + this.withCommand([ + ...(command ?? ["postgres"]), + "-c", + "ssl=on", + "-c", + `ssl_cert_file=${POSTGRES_SSL_CERT_PATH}`, + "-c", + `ssl_key_file=${POSTGRES_SSL_KEY_PATH}`, + ...(this.sslConfig.caCertPath ? ["-c", `ssl_ca_file=${this.sslConfig.caCertPath}`] : []), + ]); + } + } if (!this.healthCheck) { this.withHealthCheck({ test: [ diff --git a/packages/modules/postgresql/src/test-certs/README.md b/packages/modules/postgresql/src/test-certs/README.md new file mode 100644 index 000000000..cd4944632 --- /dev/null +++ b/packages/modules/postgresql/src/test-certs/README.md @@ -0,0 +1,22 @@ +# Test certificates + +This directory contains test-only certificates for PostgreSQL SSL module tests. + +To regenerate them: + +```bash +cd packages/modules/postgresql/src/test-certs +bash generate-certs.sh +``` + +Optional: pass validity days (default is `36500`): + +```bash +bash generate-certs.sh 3650 +``` + +Generated files: + +- `ca.crt` +- `server.crt` +- `server.key` diff --git a/packages/modules/postgresql/src/test-certs/ca.crt b/packages/modules/postgresql/src/test-certs/ca.crt new file mode 100644 index 000000000..26867139e --- /dev/null +++ b/packages/modules/postgresql/src/test-certs/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDLTCCAhWgAwIBAgIUWCfr3fMtUOOZLu1/2Xe8pKjlOC0wDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwadGVzdGNvbnRhaW5lcnMtcG9zdGdyZXMtY2EwIBcNMjYw +MjE3MDkzODQzWhgPMjEyNjAxMjQwOTM4NDNaMCUxIzAhBgNVBAMMGnRlc3Rjb250 +YWluZXJzLXBvc3RncmVzLWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAt3lgUG4xAjZ+PcXrd60lNl9OUugjNs4lLM74/X5msS36goT4264MjdDtKYq8 +L4iyUMFjp/VMxgor60geQB+A+pT7aJSVOA2ZNwrdMLnFWvziOXTNqIgaUegoXrhb +P3IFbFNlyQiRVT7sZ2YJ4yVZrpvomoXlZ4txxz9WhEGN46txnKTcO2Oj9vgUJwdA +ueUlrEQo6LAMOLFuhPt9+3GNOd+ABKvSOAJTDmhEhXPXQisZHfADgT4nmJwFSuaZ +viFxtnHoxLIru7IRu0S9mIPFRau0CfSufAdpcThv+AQW3qKngNXa2QBVikkD+dJB +OdgjrIpIdNAxTEDPYlgkl4WViQIDAQABo1MwUTAdBgNVHQ4EFgQUUa8kIh73prmS +/tAq3LTcFjhVSTIwHwYDVR0jBBgwFoAUUa8kIh73prmS/tAq3LTcFjhVSTIwDwYD +VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAdXlDyDvqAgMx/UIAFT60 +qKsM2FZf68OYlXsnFE5/Y3BfYx8qtnI+kTOkOvPbMEebfsrWJtI7Qpqy/Ih1Nuis +vgdyy+V41qU2m221MhBh4DCyfZaBJXR8PhFJjXsB0DzDWUj5MoG5ppbf2In1VT2b +prHthUnfzy8TKKZm8APv54Ln8f3Z6OtPsWMfxlXSPN/lZVsEpAuXuDmBfck6cWwl +8OXmYB9ywZv6RqpQvqWEznCnMLJFAe8ELuxo+Jzz/nHfpmYm91QTnsP5KVVghlFF +QDChMXEDRToMjhyk0c87xhLjc0KhUIGeyfFwyDbwMWZOxC0+iifyCTUQHqpTrjE6 +aw== +-----END CERTIFICATE----- diff --git a/packages/modules/postgresql/src/test-certs/generate-certs.sh b/packages/modules/postgresql/src/test-certs/generate-certs.sh new file mode 100755 index 000000000..07efc022f --- /dev/null +++ b/packages/modules/postgresql/src/test-certs/generate-certs.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CA_CERT="${SCRIPT_DIR}/ca.crt" +SERVER_CERT="${SCRIPT_DIR}/server.crt" +SERVER_KEY="${SCRIPT_DIR}/server.key" + +DAYS="${1:-36500}" + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +cat > "${TMP_DIR}/server.ext" < { it("should work", async () => { @@ -41,4 +44,25 @@ describe("TimescaleContainer", { timeout: 180_000 }, () => { await client.end(); }); + + it("should connect with SSL", async () => { + await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start(); + + const client = new Client({ + host: container.getHost(), + port: container.getPort(), + database: container.getDatabase(), + user: container.getUsername(), + password: container.getPassword(), + ssl: { + rejectUnauthorized: false, + }, + }); + await client.connect(); + + const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()"); + expect(result.rows[0]).toEqual({ ssl: true }); + + await client.end(); + }); });