diff --git a/docker-image-src/4.4/coredb/Dockerfile-debian b/docker-image-src/4.4/coredb/Dockerfile-debian index 0de6f465..3672bc52 100644 --- a/docker-image-src/4.4/coredb/Dockerfile-debian +++ b/docker-image-src/4.4/coredb/Dockerfile-debian @@ -44,7 +44,7 @@ RUN apt update \ && rm -rf /var/lib/apt/lists/* /su-exec -ENV PATH "${NEO4J_HOME}"/bin:$PATH +ENV PATH="${NEO4J_HOME}"/bin:$PATH WORKDIR "${NEO4J_HOME}" diff --git a/docker-image-src/4.4/coredb/Dockerfile-ubi9 b/docker-image-src/4.4/coredb/Dockerfile-ubi9 index be766b00..aebc55e2 100644 --- a/docker-image-src/4.4/coredb/Dockerfile-ubi9 +++ b/docker-image-src/4.4/coredb/Dockerfile-ubi9 @@ -84,7 +84,7 @@ RUN set -eux; \ ln -s /logs "${NEO4J_HOME}"/logs; \ ln -s /startup/docker-entrypoint.sh /docker-entrypoint.sh -ENV PATH "${NEO4J_HOME}"/bin:$PATH +ENV PATH="${NEO4J_HOME}"/bin:$PATH WORKDIR "${NEO4J_HOME}" diff --git a/docker-image-src/5/coredb/Dockerfile-debian b/docker-image-src/5/coredb/Dockerfile-debian index 7a106a06..8477043d 100644 --- a/docker-image-src/5/coredb/Dockerfile-debian +++ b/docker-image-src/5/coredb/Dockerfile-debian @@ -44,7 +44,7 @@ RUN apt update \ && rm -rf /var/lib/apt/lists/* /su-exec -ENV PATH "${NEO4J_HOME}"/bin:$PATH +ENV PATH="${NEO4J_HOME}"/bin:$PATH WORKDIR "${NEO4J_HOME}" diff --git a/docker-image-src/5/coredb/Dockerfile-ubi9 b/docker-image-src/5/coredb/Dockerfile-ubi9 index 9856f87f..0634bee9 100644 --- a/docker-image-src/5/coredb/Dockerfile-ubi9 +++ b/docker-image-src/5/coredb/Dockerfile-ubi9 @@ -14,7 +14,11 @@ RUN set -eux; \ ;; \ *) echo >&2 "Neo4j does not currently have a docker image for architecture $arch"; exit 1 ;; \ esac; \ + openssl_url="https://www.openssl.org/source/openssl-3.0.9.tar.gz"; \ + openssl_sha="eb1ab04781474360f77c318ab89d8c5a03abc38e63d65a603cabbf1b00a1dc90"; \ microdnf install -y --nodocs \ + apr \ + crypto-policies-scripts \ findutils \ gcc \ git \ @@ -23,6 +27,11 @@ RUN set -eux; \ java-17-openjdk-headless \ jq \ make \ + perl-Digest-SHA \ + perl-File-Compare \ + perl-File-Copy \ + perl-FindBin \ + perl-IPC-Cmd \ procps \ shadow-utils \ tar \ @@ -33,11 +42,27 @@ RUN set -eux; \ wget -q ${tini_url}.asc -O tini.asc; \ echo "${tini_sha}" /usr/bin/tini | sha256sum -c --strict --quiet; \ chmod a+x /usr/bin/tini; \ + wget -q ${openssl_url} -O /openssl.tar.gz; \ + wget -q ${openssl_url}.asc -O /openssl.tar.gz.asc; \ + # verify tini and openssl shasum and gpg signatures + echo ${openssl_sha} /openssl.tar.gz | sha256sum -c; \ export GNUPGHOME="$(mktemp -d)"; \ - gpg --batch --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys \ - 595E85A6B1B4779EA4DAAEC70B588DFF0527A9B7 \ - B42F6819007F00F88E364FD4036A9C25BF357DD4; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys \ + 6380DC428747F6C393FEACA59A84159D7001A4E5 \ + A21FAB74B0088AA361152586B8EF1A6BA9DA2D5C; \ gpg --batch --verify tini.asc /usr/bin/tini; \ + gpg --batch --verify /openssl.tar.gz.asc /openssl.tar.gz; \ + # install openssl + tar -xzf /openssl.tar.gz --directory /tmp; \ + cd /tmp/openssl-3.0.9; \ + ./Configure --prefix=/usr/local/openssl --openssldir=/usr/local/openssl enable-fips no-tests no-legacy shared; \ + make install_sw install_ssldirs install_fips; \ + if [ ${arch} = "aarch64" ]; \ + then ldconfig /usr/local/openssl/lib; \ + else ldconfig /usr/local/openssl/lib64; \ + fi; \ + cd /; \ + # download, verify and install su-exec git clone https://github.com/ncopa/su-exec.git; \ cd su-exec; \ git checkout 4c3bb42b093f14da70d8ab924b487ccfbb1397af; \ @@ -45,8 +70,9 @@ RUN set -eux; \ echo 2a87af245eb125aca9305a0b1025525ac80825590800f047419dc57bba36b334 Makefile | sha256sum -c; \ make; \ mv /su-exec/su-exec /usr/bin/su-exec; \ + # clean up build files and downloads gpgconf --kill all; \ - rm -rf "$GNUPGHOME" /tini.asc /su-exec; \ + rm -rf "$GNUPGHOME" /tini.asc /su-exec /openssl.tar.gz /openssl.tar.gz.asc /tmp/openssl*; \ microdnf remove -y git* perl* make gcc glibc-headers glibc-devel libxcrypt-devel; \ microdnf clean all @@ -80,8 +106,7 @@ RUN set -eux; \ ln -s /data "${NEO4J_HOME}"/data; \ ln -s /logs "${NEO4J_HOME}"/logs -ENV PATH "${NEO4J_HOME}"/bin:$PATH - +ENV PATH="${NEO4J_HOME}/bin:/usr/local/openssl/bin:$PATH" WORKDIR "${NEO4J_HOME}" VOLUME /data /logs diff --git a/docker-image-src/5/coredb/docker-entrypoint.sh b/docker-image-src/5/coredb/docker-entrypoint.sh index 14dc6649..5e681476 100755 --- a/docker-image-src/5/coredb/docker-entrypoint.sh +++ b/docker-image-src/5/coredb/docker-entrypoint.sh @@ -250,7 +250,9 @@ function install_neo4j_plugins function add_docker_default_to_conf { - # docker defaults should NOT overwrite values already in the conf file + # configuration settings should have this order of priority: + # neo4j default < docker default < neo4j.conf < setting from environment + # Basically, docker defaults should NOT overwrite values already explicitly set in the conf files local _setting="${1}" local _value="${2}" @@ -562,6 +564,46 @@ if [ "${NEO4J_EDITION}" == "enterprise" ]; : ${NEO4J_server_cluster_raft_advertised__address:=${NEO4J_causal__clustering_raft__advertised__address:-}} fi + +# ==== CHECK IF OPENSSL FIPS MODE IS REQUESTED ==== + +debug_msg "Deleting all netty-tcnative-boringssl jars" +find "${NEO4J_HOME}"/lib/ -iname '*boringssl*.jar' -delete + +# configure for FIPS if requested +if [[ ${NEO4J_OPENSSL_FIPS_ENABLE-} =~ [tT][rR][uU][eE] ]] +then + echo "OpenSSL FIPS mode has been requested." + if ! grep -iq "Red Hat Enterprise Linux 9" /etc/os-release; then + echo >&2 " +OpenSSL FIPS compatibility is only available in the Red Hat UBI9 Neo4j image. +To fix this error, run the UBI9 based Neo4j docker image instead. +See: +* https://neo4j.com/docs/operations-manual/current/docker/introduction for more information about Neo4j base images. +* https://neo4j.com/docs/operations-manual/current/security/ssl-framework about configuring SSL in Neo4j. + " + exit 1 + fi + _arch_str=$(arch | sed 's/aarch64/aarch_64/g') + debug_msg "Copying ${NEO4J_HOME}/plugins/netty-tcnative/netty-tcnative-*-linux-${_arch_str}.jar to ${NEO4J_HOME}/lib/" + cp -p "${NEO4J_HOME}"/lib/netty-tcnative/netty-tcnative-*-linux-${_arch_str}.jar "${NEO4J_HOME}"/lib/ + #netty_version=$(find "${NEO4J_HOME}"/lib/ -iname "netty-tcnative-classes-*" -print0 | tail -n 1 | sed -E 's/.*([0-9]+\.[0-9]+\.[0-9]+.*)\.jar/\1/g') + #debug_msg "Netty version detected as: \"${netty_version}\"" + echo "Installing FIPS module into OpenSSL" + if [ "$(rpm --query --queryformat='%{ARCH}' rpm)" = "aarch64" ]; then + ln -s /usr/local/openssl/lib /usr/local/openssl/lib64 + fi + ldconfig /usr/local/openssl/lib64 + /usr/local/openssl/bin/openssl fipsinstall -out /usr/local/openssl/fipsmodule.cnf -module /usr/local/openssl/lib64/ossl-modules/fips.so + debug_msg "Configuring OpenSSL to run with FIPS enabled" + sed -i 's/# fips = fips_sect/fips = fips_sect/g' /usr/local/openssl/openssl.cnf + sed -i 's|# \.include fipsmodule\.cnf|\.include /usr/local/openssl/fipsmodule\.cnf|g' /usr/local/openssl/openssl.cnf + sed -i -E 's/# ?config_diagnostics = 1/config_diagnostics = 1/' /usr/local/openssl/openssl.cnf + sed -i -E 'N;s/\[default_sect\]\n# activate = 1/[default_sect\]\nactivate = 1/' /usr/local/openssl/openssl.cnf + sed -i -E 'N;s/providers = provider_sect/providers = provider_sect\nalg_section = algorithm_sect\n\n[algorithm_sect]\ndefault_properties = fips=yes/' /usr/local/openssl/openssl.cnf + add_docker_default_to_conf "dbms.netty.ssl.provider" "OPENSSL" +fi + # ==== SET CONFIGURATIONS ==== ## == DOCKER SPECIFIC DEFAULT CONFIGURATIONS === @@ -586,7 +628,8 @@ fi # these are docker control envs that have the NEO4J_ prefix but we don't want to add to the config. not_configs=("NEO4J_ACCEPT_LICENSE_AGREEMENT" "NEO4J_AUTH" "NEO4J_AUTH_PATH" "NEO4J_DEBUG" "NEO4J_EDITION" \ - "NEO4J_HOME" "NEO4J_PLUGINS" "NEO4J_SHA256" "NEO4J_TARBALL" "NEO4J_DEPRECATION_WARNING") + "NEO4J_HOME" "NEO4J_PLUGINS" "NEO4J_SHA256" "NEO4J_TARBALL" \ + "NEO4J_DEPRECATION_WARNING" "NEO4J_OPENSSL_FIPS_ENABLE") debug_msg "Applying configuration settings that have been set using environment variables." # list env variables with prefix NEO4J_ and create settings from them diff --git a/docker-image-src/5/neo4j-admin/Dockerfile-debian b/docker-image-src/5/neo4j-admin/Dockerfile-debian index adb92372..e71626cd 100644 --- a/docker-image-src/5/neo4j-admin/Dockerfile-debian +++ b/docker-image-src/5/neo4j-admin/Dockerfile-debian @@ -37,7 +37,7 @@ RUN apt update \ && apt-get -y purge --auto-remove curl -ENV PATH "${NEO4J_HOME}"/bin:$PATH +ENV PATH="${NEO4J_HOME}"/bin:$PATH VOLUME /data /backups WORKDIR "${NEO4J_HOME}" diff --git a/docker-image-src/5/neo4j-admin/Dockerfile-ubi9 b/docker-image-src/5/neo4j-admin/Dockerfile-ubi9 index 724d41d9..f51cc3b3 100644 --- a/docker-image-src/5/neo4j-admin/Dockerfile-ubi9 +++ b/docker-image-src/5/neo4j-admin/Dockerfile-ubi9 @@ -36,7 +36,7 @@ RUN set -eux; \ ln -s /data "${NEO4J_HOME}"/data; \ ln -s /startup/docker-entrypoint.sh /docker-entrypoint.sh -ENV PATH "${NEO4J_HOME}"/bin:$PATH +ENV PATH="${NEO4J_HOME}"/bin:$PATH VOLUME /data /backups WORKDIR "${NEO4J_HOME}" diff --git a/pom.xml b/pom.xml index e850da91..839a64d1 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,12 @@ BundleTest + + all-tests + + + + diff --git a/src/test/java/com/neo4j/docker/coredb/TestBasic.java b/src/test/java/com/neo4j/docker/coredb/TestBasic.java index fbda0080..c6563409 100644 --- a/src/test/java/com/neo4j/docker/coredb/TestBasic.java +++ b/src/test/java/com/neo4j/docker/coredb/TestBasic.java @@ -22,6 +22,8 @@ import org.testcontainers.containers.output.Slf4jLogConsumer; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.stream.Stream; @@ -263,4 +265,22 @@ void testContainerCanBeRestartedAfterUnexpectedTermination() throws IOException waitForBoltReady( Duration.ofSeconds( 90 ) ).waitUntilReady( container ); } } + + @Test + void testExtensionScriptIsExecuted() throws IOException + { + Path scriptFolder = temporaryFolderManager.createFolder("extension_script"); + Path script = scriptFolder.resolve("startscript.sh"); + Files.writeString(script, "#!/bin/bash\n\necho \"SCRIPT EXECUTED!\""); + + try ( GenericContainer container = createBasicContainer() ) + { + temporaryFolderManager.mountHostFolderAsVolume(container, scriptFolder, "/extension"); + container.waitingFor(waitForBoltReady(Duration.ofSeconds(60))) + .withEnv("EXTENSION_SCRIPT", "/extension/startscript.sh"); + container.start(); + String logs = container.getLogs(OutputFrame.OutputType.STDOUT); + Assertions.assertTrue(logs.contains("SCRIPT EXECUTED!"), "The extension script did not get executed"); + } + } } diff --git a/src/test/java/com/neo4j/docker/coredb/TestSSL.java b/src/test/java/com/neo4j/docker/coredb/TestSSL.java new file mode 100644 index 00000000..0a06e04a --- /dev/null +++ b/src/test/java/com/neo4j/docker/coredb/TestSSL.java @@ -0,0 +1,293 @@ +package com.neo4j.docker.coredb; + +import com.neo4j.docker.utils.DatabaseIO; +import com.neo4j.docker.utils.HelperContainers; +import com.neo4j.docker.utils.Neo4jVersion; +import com.neo4j.docker.utils.SSLCertificateFactory; +import com.neo4j.docker.utils.TemporaryFolderManager; +import com.neo4j.docker.utils.TestSettings; +import com.neo4j.docker.utils.WaitStrategies; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.neo4j.driver.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; + +import java.io.FileWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + + +@Tag("BundleTest") +public class TestSSL +{ + @RegisterExtension + public static TemporaryFolderManager temporaryFolderManager = new TemporaryFolderManager(); + public static final String FIPS_FLAG = "NEO4J_OPENSSL_FIPS_ENABLE"; + public static final String PASSWORD = "MYsuperSECRETpassword123"; + public static final String SSL_KEY_PASSPHRASE = "abcdef1234567890"; + public static final String OPENSSL_VERSION = "3.0.9"; + public static final String NETTY_TCNATIVE_VERSION = "2.0.65.Final"; + public static final String OPENSSL_INSTALL_DIR = "/usr/local/openssl"; + private static final Logger log = LoggerFactory.getLogger(TestSSL.class); + private static Path tcnativeBoringSSLJar = null; + + + private void assumeFIPSCompatible() + { + Assumptions.assumeTrue(TestSettings.NEO4J_VERSION.isAtLeastVersion(new Neo4jVersion(5, 21, 0)), + "FIPS compliance was introduced after 5.21.0."); + Assumptions.assumeTrue(TestSettings.BASE_OS == TestSettings.BaseOS.UBI9, + "Test only applies to UBI9 based image."); + } + + private GenericContainer createContainer() + { + GenericContainer container = new GenericContainer<>(TestSettings.IMAGE_ID) + .withEnv("NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes") + .withEnv("NEO4J_AUTH", "neo4j/"+PASSWORD) + .withEnv("NEO4J_DEBUG", "yes") + .withExposedPorts(7474, 7687) + .withLogConsumer(new Slf4jLogConsumer(log)) + .waitingFor(WaitStrategies.waitForNeo4jReady(PASSWORD, Duration.ofSeconds(60))); + return container; + } + + private Path configureContainerForSSL(GenericContainer container) throws Exception + { + // generate certificates + Path certificates = temporaryFolderManager.createFolder("certificates"); + container.withNetworkAliases("neo4j"); + new SSLCertificateFactory(certificates) + .withSSLKeyPassphrase(SSL_KEY_PASSPHRASE, true) + .withOwnerNeo4j() + .forHostIPOrName(container.getHost()) + .build(); + temporaryFolderManager.mountHostFolderAsVolume(container, certificates, "/ssl"); + // configure Neo4j for SSL over bolt + Path conf = temporaryFolderManager.createFolderAndMountAsVolume(container, "/conf"); + FileWriter confFile = new FileWriter(conf.resolve("neo4j.conf").toFile()); + confFile.write("dbms.netty.ssl.provider=OPENSSL\n"); + confFile.write("server.https.enabled=false\n"); + confFile.write("server.bolt.tls_level=REQUIRED\n"); + confFile.write("dbms.ssl.policy.bolt.tls_level=REQUIRED\n"); + confFile.write("dbms.ssl.policy.bolt.enabled=true\n"); + confFile.write("dbms.ssl.policy.bolt.client_auth=NONE\n"); + confFile.write("dbms.ssl.policy.bolt.trust_all=false\n"); + confFile.write("dbms.ssl.policy.bolt.tls_versions=TLSv1.3\n"); + confFile.write("dbms.ssl.policy.bolt.private_key_password=$(sh -c \"" + + SSLCertificateFactory.getPassphraseDecryptCommand("/ssl")+ "\")\n"); + confFile.write("dbms.ssl.policy.bolt.base_directory=/ssl\n"); + confFile.write("dbms.ssl.policy.bolt.private_key="+SSLCertificateFactory.PRIVATE_KEY_FILENAME+"\n"); + confFile.write("dbms.ssl.policy.bolt.public_certificate="+SSLCertificateFactory.CERTIFICATE_FILENAME+"\n"); + confFile.flush(); + confFile.close(); + // use extended conf feature to expand private key passphrase + container.withEnv("EXTENDED_CONF", "true"); + Files.setPosixFilePermissions(conf.resolve("neo4j.conf"), new HashSet<>() + {{ + add(PosixFilePermission.OWNER_READ); + add(PosixFilePermission.OWNER_WRITE); + add(PosixFilePermission.GROUP_READ); + }}); + temporaryFolderManager.setFolderOwnerToNeo4j(conf.resolve("neo4j.conf")); + return certificates; + } + + @Test + void testOpenSSLIsInstalledWithFIPS() throws Exception + { + assumeFIPSCompatible(); + Container.ExecResult whichOpenSSL, versionOut, providersOut; + try(GenericContainer container = createContainer()) + { + container.withEnv(FIPS_FLAG, "true"); + container.start(); + whichOpenSSL = container.execInContainer("which", "openssl"); + log.info("OpenSSL location is \""+whichOpenSSL.getStdout()+"\""); + versionOut = container.execInContainer("openssl", "version", "-a"); + log.info("openssl version -a:\n"+versionOut.getStdout()); + providersOut = container.execInContainer("openssl", "list", "-providers"); + log.info("openssl providers:\n"+providersOut.getStdout()); + } + + // verify openssl version + Assertions.assertEquals(0, whichOpenSSL.getExitCode(), + "openssl not installed! Full output:\n" + whichOpenSSL); + Assertions.assertTrue(whichOpenSSL.getStdout().startsWith(OPENSSL_INSTALL_DIR), + "Using OpenSSL in dir " + whichOpenSSL.getStdout() + " instead of " + OPENSSL_INSTALL_DIR); + Assertions.assertEquals(0, versionOut.getExitCode(), + "OpenSSL command failed. Full output:\n" + versionOut); + List openssl = Arrays.stream(versionOut.getStdout().split("\n")).toList(); + Assertions.assertTrue(openssl.get(0).contains("OpenSSL " + OPENSSL_VERSION), + "OpenSSL "+ OPENSSL_VERSION +" is not installed.\n"+versionOut); + Assertions.assertTrue(openssl.stream().anyMatch(s -> s.matches("OPENSSLDIR:\s*\""+OPENSSL_INSTALL_DIR+"\"?\\s*")), + "OpenSSL is not using the expected ssl config directory:\n"+versionOut); + Assertions.assertTrue(openssl.stream().anyMatch(s -> s.matches("MODULESDIR:\s*\""+OPENSSL_INSTALL_DIR+"/lib(64)?/ossl-modules\"?\\s*")), + "OpenSSL is not using the expected modules directory:\n"+versionOut); + + // verify FIPS provider is set + Assertions.assertEquals(0, providersOut.getExitCode(), "OpenSSL command failed. Full output:\n"+providersOut); + List providers = Arrays.stream(providersOut.getStdout().split("\n")).map(String::trim).toList(); + Assertions.assertTrue(providers.stream().anyMatch(s -> s.matches("fips")), + "FIPS was not listed as an OpenSSL provider:\n"+providersOut); + int fipsIdx = providers.indexOf("fips"); + for(int i=fipsIdx; i < fipsIdx+3; i++) + { + String line = providers.get(i); + if (line.startsWith("name")) { + Assertions.assertTrue(line.matches("name:\\s+OpenSSL FIPS Provider"), + "FIPS provider has an unexpected name\n" + providersOut); + } else if (line.startsWith("version")) { + Assertions.assertTrue(line.matches("version:\\s+"+OPENSSL_VERSION.replace(".", "\\.")), + "FIPS version is not "+OPENSSL_VERSION+":\n" + providersOut); + } else if (line.startsWith("status")) { + Assertions.assertTrue(line.matches("status:\\s+active"), + "FIPS is not the active provider:\n" + providersOut); + } + } + } + + @Test + void testFIPSOpenSSLLibrariesUsed() throws Exception + { + assumeFIPSCompatible(); + String filesInUse; + try(GenericContainer container = createContainer()) + { + container.withEnv(FIPS_FLAG, "true"); + configureContainerForSSL(container); + container.start(); + String neo4jPID = container.execInContainer("cat", "/var/lib/neo4j/run/neo4j.pid").getStdout(); + filesInUse = container.execInContainer("su-exec", "neo4j", "cat", "/proc/"+neo4jPID+"/maps").getStdout(); + } + verifyProcessAccessesFile(filesInUse, "libssl.so", OPENSSL_INSTALL_DIR); + verifyProcessAccessesFile(filesInUse, "libcrypto.so", OPENSSL_INSTALL_DIR); + verifyProcessAccessesFile(filesInUse, "fips.so", OPENSSL_INSTALL_DIR); + } + + private void verifyProcessAccessesFile(String filesInUse, String filename, String expectedPath) + { + List fileReads = Arrays.stream(filesInUse.split("\n")) + .filter(t -> t.contains(filename)) + .toList(); + Assertions.assertFalse(fileReads.isEmpty(), "Neo4j did not use "+filename+" at all." + + " Actual files read were:\n"+filesInUse); + String regex = String.format(".*%s/.*%s.*", expectedPath, filename); + for(String line : fileReads) + { + Assertions.assertTrue(line.matches(regex), + "Did not use "+filename+" under path "+expectedPath+". Actual file: "+line); + } + } + + @Test + void shouldFailIfDebianAndFIPS() throws Exception + { + Assumptions.assumeTrue(TestSettings.BASE_OS == TestSettings.BaseOS.BULLSEYE, + "Test only applies to debian based images"); + try(GenericContainer container = createContainer()) + { + container.withEnv(FIPS_FLAG, "true"); + container.withEnv("NEO4J_AUTH", "bum/true"); + WaitStrategies.waitUntilContainerFinished(container, Duration.ofSeconds(30)); + Assertions.assertThrows(ContainerLaunchException.class, container::start); + String logs = container.getLogs(); + Assertions.assertTrue(logs.contains("OpenSSL FIPS compatibility is only available in the Red Hat UBI9 Neo4j image"), + "Did not error about FIPS compatibility in Debian"); + } + } + + @Test + void testEndToEndSSLEncryption_withFIPS() throws Exception + { + assumeFIPSCompatible(); + try(GenericContainer container = createContainer()) + { + container.withEnv(FIPS_FLAG, "true"); + verifyEndToEndSSLEncryption(container); + } + } + + @Test + void testEndToEndSSLEncryption() throws Exception + { + Assumptions.assumeFalse(TestSettings.BASE_OS == TestSettings.BaseOS.UBI9 && + System.getProperty( "os.arch" ).equals( "aarch64" ), + "BoringSSL library isn't compatible with ubi9 and arm64"); + + try(GenericContainer container = createContainer()) + { + log.info("Container host is "+container.getHost()); + container.withEnv(FIPS_FLAG, "false"); + Path pluginDir = getTCNativeBoringSSL(); + TemporaryFolderManager.mountHostFolderAsVolume(container, pluginDir.getParent(), "/plugins"); + verifyEndToEndSSLEncryption(container); + } + } + + private synchronized Path getTCNativeBoringSSL() throws Exception + { + if(tcnativeBoringSSLJar == null) + { + String boringSSLJarName = "netty-tcnative-boringssl.jar"; + try (GenericContainer container = HelperContainers.nginx()) { + Path boringjar = temporaryFolderManager.createFolderAndMountAsVolume(container, "/boringssljar"); + container.start(); + String arch = container.execInContainer("arch").getStdout().trim(); + arch = arch.replace("aarch64", "aarch_64"); // for some reason netty arm libs use aarch_64 instead of aarch64 + String url = String.format("https://repo1.maven.org/maven2/io/netty/netty-tcnative-boringssl-static/" + + "%s/netty-tcnative-boringssl-static-%s-linux-%s.jar", NETTY_TCNATIVE_VERSION, NETTY_TCNATIVE_VERSION, arch); + log.info("Downloading tcnative boringSSL from "+url); + container.execInContainer("curl", "-sSL", "-o", "/boringssljar/" + boringSSLJarName, url); + tcnativeBoringSSLJar = boringjar.resolve(boringSSLJarName); + Assertions.assertTrue(tcnativeBoringSSLJar.toFile().exists(), "Could not download TCNative BoringSSL jar"); + } + } + return tcnativeBoringSSLJar; + } + + void verifyEndToEndSSLEncryption(GenericContainer container) throws Exception + { + Path certificates = configureContainerForSSL(container); + temporaryFolderManager.createFolderAndMountAsVolume(container, "/logs"); + container.start(); + DatabaseIO dbio = new DatabaseIO(container, + Config.builder() + .withEncryption() + .withTrustStrategy(Config.TrustStrategy + .trustCustomCertificateSignedBy( + certificates.resolve(SSLCertificateFactory.CLIENT_CERTIFICATE_FILENAME).toFile() + )) + .build()); + dbio.verifyConnectivity("neo4j", PASSWORD); + dbio.putInitialDataIntoContainer("neo4j", PASSWORD); + dbio.verifyInitialDataInContainer("neo4j", PASSWORD); + + // NMap doesn't work in bullseye because the old openssl installed from apt confuses it + if(TestSettings.BASE_OS != TestSettings.BaseOS.BULLSEYE) + { + container.execInContainer("microdnf", "install", "-y", "nmap"); + String nmapOut = container.execInContainer("nmap", "--script", "ssl-enum-ciphers", "-p", "7687", "localhost").getStdout(); + log.info("nmap scan returned:\n"+nmapOut); + + List nmap = Arrays.stream(nmapOut.split("\n")) + .filter(line -> line.contains("least strength: A")) + .toList(); + Assertions.assertEquals(1, nmap.size(), + "NMap scan shows port 7687 is not secure:\n"+nmapOut); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/neo4j/docker/coredb/configurations/Configuration.java b/src/test/java/com/neo4j/docker/coredb/configurations/Configuration.java index f374f2f5..5d24f037 100644 --- a/src/test/java/com/neo4j/docker/coredb/configurations/Configuration.java +++ b/src/test/java/com/neo4j/docker/coredb/configurations/Configuration.java @@ -29,6 +29,7 @@ public class Configuration put( Setting.MEMORY_HEAP_MAXSIZE, new Configuration( "server.memory.heap.max_size")); put( Setting.MEMORY_PAGECACHE_SIZE, new Configuration("server.memory.pagecache.size")); put( Setting.MINIMUM_PASSWORD_LENGTH, new Configuration("dbms.security.auth_minimum_password_length")); + put( Setting.NETTY_SSL_PROVIDER, new Configuration("dbms.netty.ssl.provider")); put( Setting.SECURITY_PROCEDURES_UNRESTRICTED, new Configuration("dbms.security.procedures.unrestricted")); put( Setting.TXLOG_RETENTION_POLICY, new Configuration("db.tx_log.rotation.retention_policy")); }}; @@ -51,6 +52,7 @@ public class Configuration put( Setting.MEMORY_HEAP_INITIALSIZE, new Configuration("dbms.memory.heap.initial_size")); put( Setting.MEMORY_HEAP_MAXSIZE, new Configuration("dbms.memory.heap.max_size")); put( Setting.MEMORY_PAGECACHE_SIZE, new Configuration("dbms.memory.pagecache.size")); + put( Setting.NETTY_SSL_PROVIDER, new Configuration("dbms.netty.ssl.provider")); put( Setting.SECURITY_PROCEDURES_UNRESTRICTED, new Configuration("dbms.security.procedures.unrestricted")); put( Setting.TXLOG_RETENTION_POLICY, new Configuration("dbms.tx_log.rotation.retention_policy")); }}; @@ -87,8 +89,8 @@ public static Path getConfigurationResourcesFolder( Neo4jVersion version ) else return Paths.get( "src", "test", "resources", "confs", "before50"); } - public String name; - public String envName; + public final String name; + public final String envName; private Configuration( String name ) { diff --git a/src/test/java/com/neo4j/docker/coredb/configurations/Setting.java b/src/test/java/com/neo4j/docker/coredb/configurations/Setting.java index 80792273..5d1bb520 100644 --- a/src/test/java/com/neo4j/docker/coredb/configurations/Setting.java +++ b/src/test/java/com/neo4j/docker/coredb/configurations/Setting.java @@ -20,6 +20,7 @@ public enum Setting MEMORY_HEAP_MAXSIZE, MEMORY_PAGECACHE_SIZE, MINIMUM_PASSWORD_LENGTH, + NETTY_SSL_PROVIDER, SECURITY_PROCEDURES_UNRESTRICTED, TXLOG_RETENTION_POLICY } diff --git a/src/test/java/com/neo4j/docker/coredb/configurations/TestExtendedConf.java b/src/test/java/com/neo4j/docker/coredb/configurations/TestExtendedConf.java index 881195b4..01af94f1 100644 --- a/src/test/java/com/neo4j/docker/coredb/configurations/TestExtendedConf.java +++ b/src/test/java/com/neo4j/docker/coredb/configurations/TestExtendedConf.java @@ -59,7 +59,7 @@ protected GenericContainer createContainer(String password) GenericContainer container = new GenericContainer( TestSettings.IMAGE_ID ) .withEnv( "NEO4J_AUTH", password == null || password.isEmpty() ? "none" : "neo4j/" + password ) .withEnv( "NEO4J_ACCEPT_LICENSE_AGREEMENT", "yes" ) - .withEnv( "EXTENDED_CONF", "yeppers" ) + .withEnv( "EXTENDED_CONF", "true" ) .withExposedPorts( 7474, 7687 ) .withLogConsumer( new Slf4jLogConsumer( log ) ) .waitingFor( WaitStrategies.waitForBoltReady( Duration.ofSeconds(90))); diff --git a/src/test/java/com/neo4j/docker/coredb/plugins/TestBundledPluginInstallation.java b/src/test/java/com/neo4j/docker/coredb/plugins/TestBundledPluginInstallation.java index 49cdcea8..64e6518c 100644 --- a/src/test/java/com/neo4j/docker/coredb/plugins/TestBundledPluginInstallation.java +++ b/src/test/java/com/neo4j/docker/coredb/plugins/TestBundledPluginInstallation.java @@ -208,7 +208,7 @@ public void testBundledPlugin_downloadsIfNotAvailableLocally() throws Exception if(plugins.isEmpty()) { // no plugins were downloaded, which is correct if we are testing an unreleased neo4j - String expectedError = String.format(".*No compatible \"%s\" plugin found for Neo4j %s community\\.", + String expectedError = String.format(".*No compatible \"%s\" plugin found for Neo4j %s.*", plugin.name, TestSettings.NEO4J_VERSION); Assertions.assertTrue( Stream.of(errlogs.split( "\n" )) diff --git a/src/test/java/com/neo4j/docker/utils/DatabaseIO.java b/src/test/java/com/neo4j/docker/utils/DatabaseIO.java index 97a103ef..b3b191a4 100644 --- a/src/test/java/com/neo4j/docker/utils/DatabaseIO.java +++ b/src/test/java/com/neo4j/docker/utils/DatabaseIO.java @@ -21,16 +21,23 @@ public class DatabaseIO { - private static Config TEST_DRIVER_CONFIG = Config.builder().withoutEncryption().build(); + private static Config DEFAULT_DRIVER_CONFIG = Config.builder().withoutEncryption().build(); private static final Logger log = LoggerFactory.getLogger( DatabaseIO.class ); private GenericContainer container; private String boltUri; + private Config driverConfig; public DatabaseIO( GenericContainer container ) + { + this(container, DEFAULT_DRIVER_CONFIG); + } + + public DatabaseIO( GenericContainer container, Config driverConfig) { this.container = container; this.boltUri = "bolt://"+container.getHost()+":"+container.getMappedPort( 7687 ); + this.driverConfig = driverConfig; } public DatabaseIO( String host, Integer boltPort ) @@ -124,7 +131,7 @@ public List runCypherQuery( String user, String password, String cypher) // we don't just do runCypherQuery( user, password, cypher, "neo4j") // because it breaks the upgrade tests from 3.5.x List records; - Driver driver = GraphDatabase.driver( boltUri, getToken( user, password ), TEST_DRIVER_CONFIG ); + Driver driver = GraphDatabase.driver( boltUri, getToken( user, password ), this.driverConfig); try ( Session session = driver.session()) { Result rs = session.run( cypher ); @@ -137,7 +144,7 @@ public List runCypherQuery( String user, String password, String cypher) public List runCypherQuery( String user, String password, String cypher, String database) { List records; - Driver driver = GraphDatabase.driver( boltUri, getToken( user, password ), TEST_DRIVER_CONFIG ); + Driver driver = GraphDatabase.driver( boltUri, getToken( user, password ), this.driverConfig); try ( Session session = driver.session(SessionConfig.forDatabase( database ))) { Result rs = session.run( cypher ); @@ -150,9 +157,9 @@ public List runCypherQuery( String user, String password, String cypher, public void verifyConnectivity( String user, String password ) { GraphDatabase.driver( boltUri, - getToken( user, password ), - TEST_DRIVER_CONFIG ) - .verifyConnectivity(); + getToken( user, password ), + this.driverConfig) + .verifyConnectivity(); } private AuthToken getToken(String user, String password) diff --git a/src/test/java/com/neo4j/docker/utils/HelperContainers.java b/src/test/java/com/neo4j/docker/utils/HelperContainers.java new file mode 100644 index 00000000..e7a3ab95 --- /dev/null +++ b/src/test/java/com/neo4j/docker/utils/HelperContainers.java @@ -0,0 +1,18 @@ +package com.neo4j.docker.utils; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +public class HelperContainers +{ + public static GenericContainer nginx() + { + return new GenericContainer(DockerImageName.parse("nginx:latest")) + .withExposedPorts(80) + .waitingFor(Wait.forHttp("/") + .withStartupTimeout(Duration.ofSeconds(20))); + } +} diff --git a/src/test/java/com/neo4j/docker/utils/SSLCertificateFactory.java b/src/test/java/com/neo4j/docker/utils/SSLCertificateFactory.java new file mode 100644 index 00000000..960a80b5 --- /dev/null +++ b/src/test/java/com/neo4j/docker/utils/SSLCertificateFactory.java @@ -0,0 +1,136 @@ +package com.neo4j.docker.utils; + +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +public class SSLCertificateFactory +{ + public static final String CERTIFICATE_FILENAME = "selfsigned.crt"; + // Certificates need to be owned by the user inside the container, which means that tests using drivers + // cannot authenticate because they do not have permission to read the certificate file. + // The factory creates a copy of the certificate that can always be used by the test's client side. + public static final String CLIENT_CERTIFICATE_FILENAME = "local"+CERTIFICATE_FILENAME; + public static final String PRIVATE_KEY_FILENAME = "private.key"; + public static final String ENCRYPTED_PASSPHRASE_FILENAME = "passphrase.enc"; + protected static final String DEFAULT_HOST_NAME = "localhost"; + private static final Path SSL_RESOURCES = Paths.get("src", "test", "resources", "ssl"); + private final Path outputFolder; + private String passphrase = null; + private String hostNameOrIP = DEFAULT_HOST_NAME; + private boolean isPassphraseEncrypted = false; + private String owner = null; + + public SSLCertificateFactory(Path outputFolder) + { + this.outputFolder = outputFolder; + } + + public SSLCertificateFactory withSSLKeyPassphrase(String passphrase, boolean isPassphraseEncrypted) + { + this.passphrase = passphrase; + this.isPassphraseEncrypted = isPassphraseEncrypted; + return this; + } + public SSLCertificateFactory withoutSSLKeyPassphrase() + { + this.passphrase = null; + this.isPassphraseEncrypted = false; + return this; + } + + public SSLCertificateFactory withOwnerNeo4j() + { + this.owner = SetContainerUser.getNeo4jUserString(); + return this; + } + + public SSLCertificateFactory withOwnerNonRootUser() + { + this.owner = SetContainerUser.getNonRootUserString(); + return this; + } + + public SSLCertificateFactory forHostIPOrName(String host) + { + this.hostNameOrIP = host; + return this; + } + + private void appendHostSettingToConfig(Path config) throws IOException + { + if(this.hostNameOrIP.matches("\\d+\\.\\d+\\.\\d+\\.\\d+")) + { + Files.writeString(config, "IP.0 = "+this.hostNameOrIP +"\n", StandardOpenOption.APPEND); + } + else + { + Files.writeString(config, "DNS.1 = "+this.hostNameOrIP +"\n", StandardOpenOption.APPEND); + } + } + + public void build() throws Exception + { + if(this.owner == null) + { + throw new IllegalArgumentException("File owner has not been set for SSL certificates. This is a test error."); + } + try (GenericContainer container = HelperContainers.nginx()) + { + String mountpoint = "/certgen"; + TemporaryFolderManager.mountHostFolderAsVolume(container, this.outputFolder, mountpoint); + + // copy ssl certificate generating script to mounted folder + Path scriptPath = this.outputFolder.resolve("gen-scripts"); + Files.createDirectory(scriptPath); + Files.copy(SSL_RESOURCES.resolve("server.conf"), scriptPath.resolve("server.conf")); + Files.copy(SSL_RESOURCES.resolve("gen-ssl-cert.sh"), scriptPath.resolve("gen-ssl-cert.sh")); + appendHostSettingToConfig(scriptPath.resolve("server.conf")); + + container.withWorkingDirectory(mountpoint + "/gen-scripts"); + container.start(); + + List genCertCommand = new ArrayList<>(); + genCertCommand.add("./gen-ssl-cert.sh"); + genCertCommand.add("--folder"); + genCertCommand.add(mountpoint); + if(this.passphrase != null) + { + genCertCommand.add("--passphrase"); + genCertCommand.add(this.passphrase); + } + if(this.isPassphraseEncrypted) + { + genCertCommand.add("--encrypt"); + } + + Container.ExecResult cmd = container.execInContainer(genCertCommand.toArray(String[]::new)); + if(cmd.getExitCode() != 0) + { + throw new IllegalArgumentException("Could not generate SSL keys. Error:\n" + cmd); + } + + container.execInContainer("chown", "-R", this.owner, mountpoint); + // copy the certificate and make it readable by the user running the unit tests + // otherwise we can't validate commands on the client side + container.execInContainer("cp", mountpoint+"/"+CERTIFICATE_FILENAME, + mountpoint+"/"+CLIENT_CERTIFICATE_FILENAME); + container.execInContainer("chown", SetContainerUser.getNonRootUserString(), mountpoint+"/"+CLIENT_CERTIFICATE_FILENAME); + container.execInContainer("sh", "-c", + String.format("cd %s; rm -rf %s/%s", mountpoint, mountpoint, scriptPath.getFileName())); + } + } + + public static String getPassphraseDecryptCommand(String certificatesMountPoint) + { + return String.format("base64 -w 0 %s/selfsigned.crt | openssl aes-256-cbc -a -d " + + "-in %s/%s -pass stdin", certificatesMountPoint, certificatesMountPoint, ENCRYPTED_PASSPHRASE_FILENAME); + } +} diff --git a/src/test/java/com/neo4j/docker/utils/SSLCertificateFactoryTest.java b/src/test/java/com/neo4j/docker/utils/SSLCertificateFactoryTest.java new file mode 100644 index 00000000..75fadd02 --- /dev/null +++ b/src/test/java/com/neo4j/docker/utils/SSLCertificateFactoryTest.java @@ -0,0 +1,230 @@ +package com.neo4j.docker.utils; + +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +// This is a test for a test utility. It does not actually test anything to do with the docker image. +// This is disabled unless we're actually trying to develop/fix the SSLCertificateFactory utility. +@Disabled +public class SSLCertificateFactoryTest +{ + private static final int NEO4J_USER_ID = Integer.parseInt(SetContainerUser.getNeo4jUserString().split(":")[0]); + private static final int CURRENT_USER_ID = Integer.parseInt(SetContainerUser.getNonRootUserString().split(":")[0]); + + @RegisterExtension + static TemporaryFolderManager folderManager = new TemporaryFolderManager(); + + @Test + void generatesUnencryptedCertificateAndKey() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .withoutSSLKeyPassphrase() + .build(); + File cert = outdir.resolve(SSLCertificateFactory.CERTIFICATE_FILENAME).toFile(); + File clientCert = outdir.resolve(SSLCertificateFactory.CLIENT_CERTIFICATE_FILENAME).toFile(); + File key = outdir.resolve(SSLCertificateFactory.PRIVATE_KEY_FILENAME).toFile(); + + // verify the files exist + Assertions.assertTrue(cert.exists(), "Certificate was not created"); + Assertions.assertTrue(key.exists(), "Private key was not created"); + Assertions.assertTrue(clientCert.exists(), "Client side certificate was not created"); + Assertions.assertTrue(clientCert.canRead(), "Client certificate is not readable by test user"); + + verifyFileOwnership(outdir, NEO4J_USER_ID); + verifyCertificatesAndKey(outdir, SSLCertificateFactory.DEFAULT_HOST_NAME, null); + } + + @Test + void generatesEncryptedCertificateAndKey() throws Exception + { + String passphrase = "a123long456passphrase"; + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .withSSLKeyPassphrase(passphrase, false) + .build(); + verifyFileOwnership(outdir, NEO4J_USER_ID); + verifyCertificatesAndKey(outdir, SSLCertificateFactory.DEFAULT_HOST_NAME, passphrase); + } + + @Test + void shouldEncryptKeyPassphrase() throws Exception + { + String passphrase = "a123long456passphrase"; + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .withSSLKeyPassphrase(passphrase, true) + .build(); + Path encryptedPassphrase = outdir.resolve(SSLCertificateFactory.ENCRYPTED_PASSPHRASE_FILENAME); + Assertions.assertTrue(encryptedPassphrase.toFile().exists(), "Encrypted passphrase file was not created"); + verifyFileOwnership(outdir, NEO4J_USER_ID); + verifyCertificatesAndKey(outdir, SSLCertificateFactory.DEFAULT_HOST_NAME, passphrase); + + // verify the decrypt command works + try(GenericContainer container = HelperContainers.nginx()) + { + TemporaryFolderManager.mountHostFolderAsVolume(container, outdir, "/certificates"); + String decryptCommand = SSLCertificateFactory.getPassphraseDecryptCommand("/certificates"); + container.start(); + Container.ExecResult decryptResult = container.execInContainer("sh", "-c", decryptCommand); + Assertions.assertEquals(0, decryptResult.getExitCode(), "decrypt command unsuccessful"); + Assertions.assertEquals(passphrase, decryptResult.getStdout().trim(), + "The decrypt command does not successfully decrypt the passphrase.\n"+decryptResult); + } + } + + @Test + void shouldSetOwnerCurrentUser() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + + new SSLCertificateFactory(outdir) + .withOwnerNonRootUser() + .withSSLKeyPassphrase("somepassphrasedoesntmatter", true) + .build(); + verifyFileOwnership(outdir, CURRENT_USER_ID); + } + + @Test + void shouldCreateUnencryptedKeyByDefault() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .build(); + verifyCertificatesAndKey(outdir, SSLCertificateFactory.DEFAULT_HOST_NAME, null); + } + + @Test + void shouldSetHostCoveredByCertificate_hostIsIP() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .forHostIPOrName("10.0.1.42") + .withSSLKeyPassphrase("password12345", true) + .build(); + verifyCertificatesAndKey(outdir, "10.0.1.42", "password12345"); + } + + @Test + void shouldSetHostCoveredByCertificate_hostIsLocalhost() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .forHostIPOrName("localhost") + .withSSLKeyPassphrase("password12345", true) + .build(); + verifyCertificatesAndKey(outdir, "localhost", "password12345"); + } + + @Test + void shouldSetHostCoveredByCertificate_hostIsSomeName() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + new SSLCertificateFactory(outdir) + .withOwnerNeo4j() + .forHostIPOrName("myserver") + .withSSLKeyPassphrase("password12345", true) + .build(); + verifyCertificatesAndKey(outdir, "myserver", "password12345"); + } + + @Test + void shouldErrorIfNoOwnerSet() throws Exception + { + Path outdir = folderManager.createFolder("certificates"); + SSLCertificateFactory factory = new SSLCertificateFactory(outdir); + Assertions.assertThrows(IllegalArgumentException.class, ()->factory.build(), + "Should have thrown error that file owner has not been set"); + } + + private void verifyFileOwnership(Path certificateDir, int expectedUID) throws Exception + { + Path cert = certificateDir.resolve(SSLCertificateFactory.CERTIFICATE_FILENAME); + Path clientCert = certificateDir.resolve(SSLCertificateFactory.CLIENT_CERTIFICATE_FILENAME); + Path key = certificateDir.resolve(SSLCertificateFactory.PRIVATE_KEY_FILENAME); + Path passphrase = certificateDir.resolve(SSLCertificateFactory.ENCRYPTED_PASSPHRASE_FILENAME); + + Assertions.assertEquals(expectedUID, Files.getAttribute(cert, "unix:uid"), + "Owner of certificate was not set to "+expectedUID); + Assertions.assertEquals(CURRENT_USER_ID, Files.getAttribute(clientCert, "unix:uid"), + "Owner of client certificate was not set to "+CURRENT_USER_ID); + Assertions.assertEquals(expectedUID, Files.getAttribute(key, "unix:uid"), + "Owner of private key was not set to "+expectedUID); + if(passphrase.toFile().exists()) + { + Assertions.assertEquals(expectedUID, Files.getAttribute(passphrase, "unix:uid"), + "Owner of encrypted passphrase was not set to "+expectedUID); + } + } + + private void verifyCertificatesAndKey(Path certificateDir, String expectedHostName, @Nullable String keyPassphrase) throws Exception + { + try(GenericContainer container = HelperContainers.nginx()) + { + TemporaryFolderManager.mountHostFolderAsVolume(container, certificateDir, "/certificates"); + container.start(); + // verify certificates and key are pem format and match + // the `-inform pem` means that the commands will fail if certs/keys are not in PEM format. + Container.ExecResult certModulus = container.execInContainer("openssl", "x509", + "-in", "/certificates/"+SSLCertificateFactory.CERTIFICATE_FILENAME, + "-inform", "pem", + "-noout", "-modulus"); + Container.ExecResult certSAN = container.execInContainer("sh", "-c", + "openssl x509 -text -noout -in /certificates/"+SSLCertificateFactory.CERTIFICATE_FILENAME + + " | grep \"Subject Alternative Name\" -A1"); + Container.ExecResult clientCertModulus = container.execInContainer("openssl", "x509", + "-in", "/certificates/"+SSLCertificateFactory.CLIENT_CERTIFICATE_FILENAME, + "-inform", "pem", + "-noout", "-modulus"); + Container.ExecResult keyModulus; + if(keyPassphrase == null) + { + keyModulus = container.execInContainer("openssl", "rsa", + "-in", "/certificates/" + SSLCertificateFactory.PRIVATE_KEY_FILENAME, + "-inform", "pem", + "-noout", "-modulus"); + } + else + { + // verify cannot read the key without a passphrase + Container.ExecResult keyRead = container.execInContainer("openssl", "rsa", + "-in", "/certificates/" + SSLCertificateFactory.PRIVATE_KEY_FILENAME, + "-inform", "pem", "-noout"); + Assertions.assertNotEquals(0, keyRead.getExitCode(), + "Should have failed to read private key without passphrase."); + // now read the key + keyModulus = container.execInContainer("openssl", "rsa", + "-in", "/certificates/" + SSLCertificateFactory.PRIVATE_KEY_FILENAME, + "-inform", "pem", + "-noout", "-modulus", + "-passin", "pass:"+keyPassphrase); + } + Assertions.assertEquals(0, certModulus.getExitCode(), + "Certificate was not created in x509 PEM format.\n"+certModulus); + Assertions.assertEquals(0, clientCertModulus.getExitCode(), + "Client certificate was not created in x509 PEM format.\n"+clientCertModulus); + Assertions.assertEquals(0, keyModulus.getExitCode(), + "Private key was not created in PEM format.\n"+keyModulus); + Assertions.assertEquals(certModulus.getStdout(), keyModulus.getStdout(), "Certificate and private key do not match"); + Assertions.assertEquals(clientCertModulus.getStdout(), keyModulus.getStdout(), "Client certificate and private key do not match"); + Assertions.assertFalse(certSAN.getStdout().isEmpty(), "Certificate should have a Subject Alternative Name"); + Assertions.assertTrue(certSAN.getStdout().contains(expectedHostName), + "Certificate should list IP "+expectedHostName+" as an address covered"); + } + } +} diff --git a/src/test/java/com/neo4j/docker/utils/SetContainerUser.java b/src/test/java/com/neo4j/docker/utils/SetContainerUser.java index 7dc36600..5196a332 100644 --- a/src/test/java/com/neo4j/docker/utils/SetContainerUser.java +++ b/src/test/java/com/neo4j/docker/utils/SetContainerUser.java @@ -27,6 +27,11 @@ public static String getNonRootUserString() } } + public static String getNeo4jUserString() + { + return "7474:7474"; + } + private static String getCurrentlyRunningUser() { UnixSystem fs = new UnixSystem(); diff --git a/src/test/java/com/neo4j/docker/utils/TemporaryFolderManager.java b/src/test/java/com/neo4j/docker/utils/TemporaryFolderManager.java index 3338b233..5c8ff1c2 100644 --- a/src/test/java/com/neo4j/docker/utils/TemporaryFolderManager.java +++ b/src/test/java/com/neo4j/docker/utils/TemporaryFolderManager.java @@ -10,18 +10,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testcontainers.containers.BindMode; -import org.testcontainers.containers.Container; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.shaded.org.apache.commons.io.IOUtils; -import org.testcontainers.utility.DockerImageName; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.util.HashSet; import java.util.List; import java.util.Random; @@ -69,7 +65,7 @@ * CLASSNAME_METHODNAME_RANDOMNUMBER and inside it, there will be a folder called conf and * a folder called logs. These will be mounted to container at /conf and /logs. *

- * For example if your test class is TestMounting.java and the method is called shouldWriteToMount + * For example if your test class is TestMounting.java and the method is called shouldWriteToMount * the folders created will be together inside com.neo4j.docker.coredb.TestMounting_shouldWriteToMount_RANDOMNUMBER. * *

HARDER: Mount the same folder to two different (consecutive) containers

@@ -126,9 +122,9 @@ public void beforeEach( ExtensionContext extensionContext ) throws Exception methodOutputFolderName += "_" + extensionContext.getDisplayName() .replace( ' ', '_' ); } - // finally add some salt so that we can run the same test method twice and not get naming clashes. + // finally add some salt so that we can run the same test method twice and not get naming clashes. methodOutputFolderName += String.format( "_%04d", rng.nextInt(10000 ) ); - log.info( "Recommended folder prefix is " + methodOutputFolderName ); + log.info( "Test output folder is " + methodOutputFolderName ); methodOutputFolder = folderRoot.resolve( methodOutputFolderName ); } @@ -149,31 +145,7 @@ public void triggerCleanup() throws Exception // create tar archive of data for(Path p : toCompressAfterAll) { - String tarOutName = p.getFileName().toString() + ".tar.gz"; - try ( OutputStream fo = Files.newOutputStream( p.getParent().resolve( tarOutName ) ); - OutputStream gzo = new GzipCompressorOutputStream( fo ); - TarArchiveOutputStream archiver = new TarArchiveOutputStream( gzo ) ) - { - archiver.setLongFileMode( TarArchiveOutputStream.LONGFILE_POSIX ); - List files = Files.walk( p ).toList(); - for(Path fileToBeArchived : files) - { - // don't archive directories... - if(fileToBeArchived.toFile().isDirectory()) continue; - try( InputStream fileStream = Files.newInputStream( fileToBeArchived )) - { - ArchiveEntry entry = archiver.createArchiveEntry( fileToBeArchived, folderRoot.relativize( fileToBeArchived ).toString() ); - archiver.putArchiveEntry( entry ); - IOUtils.copy( fileStream, archiver ); - archiver.closeArchiveEntry(); - } catch (IOException ioe) - { - // consume the error, because sometimes, file permissions won't let us copy - log.warn( "Could not archive "+ fileToBeArchived, ioe); - } - } - archiver.finish(); - } + createTarGzOfPath(p); } // delete original folders log.debug( "Re owning folders: {}", toCompressAfterAll.stream() @@ -204,23 +176,14 @@ public Path createFolderAndMountAsVolume( GenericContainer container, String con return tempFolder; } -// public Path createNamedFolderAndMountAsVolume( GenericContainer container, String hostFolderName, -// Path parentFolder, String containerMountPoint ) throws IOException -// { -// Path tempFolder = createFolder( hostFolderName, parentFolder ); -// mountHostFolderAsVolume( container, tempFolder, containerMountPoint ); -// return tempFolder; -// } - -// public Path createFolderAndMountAsVolume( GenericContainer container, String containerMountPoint, Path parentFolder ) throws IOException -// { -// return null; -// Path hostFolder = createTempFolder( hostFolderNamePrefix, parentFolder ); -// mountHostFolderAsVolume( container, hostFolder, containerMountPoint ); -// return hostFolder; -// } + protected String getFolderNameFromMountPoint(String containerMountPoint) + { + return containerMountPoint.substring( 1 ) + .replace( '/', '_' ) + .replace( ' ', '_' ); + } - public void mountHostFolderAsVolume(GenericContainer container, Path hostFolder, String containerMountPoint) + public static void mountHostFolderAsVolume(GenericContainer container, Path hostFolder, String containerMountPoint) { container.withFileSystemBind( hostFolder.toAbsolutePath().toString(), containerMountPoint, @@ -263,25 +226,15 @@ public void setFolderOwnerToCurrentUser(Path file) throws Exception public void setFolderOwnerToNeo4j(Path file) throws Exception { - setFolderOwnerTo( "7474:7474", file ); - } - - protected String getFolderNameFromMountPoint(String containerMountPoint) - { - return containerMountPoint.substring( 1 ) - .replace( '/', '_' ) - .replace( ' ', '_' ); + setFolderOwnerTo( SetContainerUser.getNeo4jUserString(), file ); } private void setFolderOwnerTo(String userAndGroup, Path... files) throws Exception { // uses docker privileges to set file owner, since probably the current user is not a sudoer. - // Using nginx because it's easy to verify that the image started. - try(GenericContainer container = new GenericContainer( DockerImageName.parse( "nginx:latest"))) + try(GenericContainer container = HelperContainers.nginx()) { - container.withExposedPorts( 80 ) - .waitingFor( Wait.forHttp( "/" ).withStartupTimeout( Duration.ofSeconds( 20 ) ) ); for(Path p : files) { mountHostFolderAsVolume( container, p, p.toAbsolutePath().toString() ); @@ -289,11 +242,39 @@ private void setFolderOwnerTo(String userAndGroup, Path... files) throws Excepti container.start(); for(Path p : files) { - Container.ExecResult x = - container.execInContainer( "chown", "-R", userAndGroup, - p.toAbsolutePath().toString() ); + container.execInContainer( "chown", "-R", userAndGroup, p.toAbsolutePath().toString() ); } container.stop(); } } + + private Path createTarGzOfPath(Path pathToArchive) throws IOException + { + Path outTarGz = pathToArchive.getParent().resolve(pathToArchive.getFileName().toString() + ".tar.gz"); + try ( OutputStream fo = Files.newOutputStream( outTarGz ); + OutputStream gzo = new GzipCompressorOutputStream( fo ); + TarArchiveOutputStream archiver = new TarArchiveOutputStream( gzo ) ) + { + archiver.setLongFileMode( TarArchiveOutputStream.LONGFILE_POSIX ); + List files = Files.walk( pathToArchive ).toList(); + for(Path fileToArchive : files) + { + // don't archive directories... + if(fileToArchive.toFile().isDirectory()) continue; + try( InputStream fileStream = Files.newInputStream( fileToArchive )) + { + ArchiveEntry entry = archiver.createArchiveEntry( fileToArchive, folderRoot.relativize( fileToArchive ).toString() ); + archiver.putArchiveEntry( entry ); + IOUtils.copy( fileStream, archiver ); + archiver.closeArchiveEntry(); + } catch (IOException ioe) + { + // consume the error, because sometimes, file permissions won't let us copy + log.warn( "Could not archive "+ fileToArchive, ioe); + } + } + archiver.finish(); + } + return outTarGz; + } } diff --git a/src/test/java/com/neo4j/docker/utils/TemporaryFolderManagerTest.java b/src/test/java/com/neo4j/docker/utils/TemporaryFolderManagerTest.java index 486ab5ee..48fcff66 100644 --- a/src/test/java/com/neo4j/docker/utils/TemporaryFolderManagerTest.java +++ b/src/test/java/com/neo4j/docker/utils/TemporaryFolderManagerTest.java @@ -16,9 +16,7 @@ import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.shaded.org.apache.commons.io.IOUtils; -import org.testcontainers.utility.DockerImageName; import java.io.ByteArrayOutputStream; import java.io.File; @@ -26,7 +24,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @@ -148,7 +145,7 @@ void autoGeneratesSensibleFolderNameFromMountPoint(String mountPoint, String exp @Test void shouldMountAnyFolderToContainer(@TempDir Path tempFolder) throws Exception { - try(GenericContainer container = makeContainer()) + try(GenericContainer container = HelperContainers.nginx()) { manager.mountHostFolderAsVolume( container, tempFolder, "/root" ); container.start(); @@ -217,7 +214,7 @@ void createNamedFolderAndMount() throws Exception String expectedMethodNameFolderRegex = this.getClass().getName() + "_createNamedFolderAndMount_\\d{4}"; String expectedFolderName = "aFolder"; Path actualTempFolder; - try(GenericContainer container = makeContainer()) + try(GenericContainer container = HelperContainers.nginx()) { actualTempFolder = manager.createNamedFolderAndMountAsVolume( container, expectedFolderName, "/root" ); container.start(); @@ -243,7 +240,7 @@ void createAutomaticallyNamedFolderAndMount() throws Exception String expectedMethodNameFolderRegex = this.getClass().getName() + "_createAutomaticallyNamedFolderAndMount_\\d{4}"; String expectedFolderName = "root"; Path actualTempFolder; - try(GenericContainer container = makeContainer()) + try(GenericContainer container = HelperContainers.nginx()) { actualTempFolder = manager.createFolderAndMountAsVolume( container, "/root" ); container.start(); @@ -483,16 +480,6 @@ void canCreateAndCleanupFoldersWithDifferentOwners() throws Exception Assertions.assertFalse( tempFolder7474.toFile().exists(), "Did not successfully delete "+tempFolder7474 ); } - private GenericContainer makeContainer() - { - // we don't want to test the neo4j container, just use a generic container debian to check mounting. - // using nginx here just because there is a straightforward way of waiting for it to be ready - GenericContainer container = new GenericContainer(DockerImageName.parse("nginx:latest")) - .withExposedPorts(80) - .waitingFor(Wait.forHttp("/").withStartupTimeout( Duration.ofSeconds( 5 ) )); - return container; - } - private List listFilesInTar(File tar) throws IOException { List files = new ArrayList<>(); diff --git a/src/test/resources/ssl/gen-ssl-cert.sh b/src/test/resources/ssl/gen-ssl-cert.sh new file mode 100755 index 00000000..780393a9 --- /dev/null +++ b/src/test/resources/ssl/gen-ssl-cert.sh @@ -0,0 +1,77 @@ +#!/bin/bash -e + +SRCFOLDER=$(dirname $0) +OUTFOLDER="/certgen" +ENCRYPT_PASSPHRASE=false +SUBJ="/C=SE/O=Example/OU=ExampleCluster/CN=localhost" + +while true; do + case "$1" in + -f | --folder ) OUTFOLDER="$2"; shift 2 ;; + -p | --passphrase ) PASSPHRASE="$2"; shift 2 ;; + -e | --encrypt ) ENCRYPT_PASSPHRASE=true; shift ;; + * ) break ;; + esac +done +# echo "OUTFOLDER: ${OUTFOLDER}" +# echo "PASSPHRASE: ${PASSPHRASE}" +# echo "DO ENCRYPT: ${ENCRYPT_PASSPHRASE}" + +if ${ENCRYPT_PASSPHRASE} && [ -z "${PASSPHRASE}" ]; then + echo >&2 "Passphrase encryption requested, but no passphrase given." + exit 1 +fi + +echo "generating private key" +if [ -n "${PASSPHRASE}" ]; then # if a passphrase was set + PASSPHRASE_IN_ARG="-passin=pass:${PASSPHRASE}" + PASSPHRASE_OUT_ARG="-passout=pass:${PASSPHRASE}" +else # if no passphrase + PASSPHRASE_IN_ARG= + PASSPHRASE_OUT_ARG="-nocrypt" +fi + +echo "Generating self signed SSL certificate into ${OUTFOLDER}" +openssl req -x509 -sha256 -nodes -newkey rsa:2048 -days 1 \ + -keyout "${OUTFOLDER}/private.key1" \ + -config "${SRCFOLDER}/server.conf" \ + -out "${OUTFOLDER}/selfsigned.crt" \ + -subj ${SUBJ} + +echo "converting private key to pkcs8 format" +openssl pkcs8 -topk8 \ + -in "${OUTFOLDER}/private.key1" \ + -out "${OUTFOLDER}/private.key" \ + ${PASSPHRASE_OUT_ARG} +rm "${OUTFOLDER}/private.key1" +rm "${OUTFOLDER}/selfsigned.crt" + +echo "Generating certificate sign request" +openssl req -new -nodes -utf8 \ + -key "${OUTFOLDER}/private.key" \ + -config server.conf \ + -extensions csr_reqext \ + -out "${OUTFOLDER}/selfsigned.csr" \ + -subj ${SUBJ} \ + ${PASSPHRASE_IN_ARG} + +echo "making signing request for normal certificate" +openssl req -x509 -nodes -utf8 -days 1 \ + -in "${OUTFOLDER}/selfsigned.csr" \ + -key "${OUTFOLDER}/private.key" \ + -config server.conf \ + -extensions server_reqext \ + -out "${OUTFOLDER}/selfsigned.crt" \ + ${PASSPHRASE_IN_ARG} + +if ${ENCRYPT_PASSPHRASE}; then + echo "Creating encrypted passphrase file" + echo "${PASSPHRASE}" > "${OUTFOLDER}/passfile" + base64 -w 0 "${OUTFOLDER}/selfsigned.crt" | \ + openssl aes-256-cbc -a -salt \ + -pass stdin \ + -in "${OUTFOLDER}/passfile" \ + -out "${OUTFOLDER}/passphrase.enc" + rm "${OUTFOLDER}/passfile" +fi + diff --git a/src/test/resources/ssl/server.conf b/src/test/resources/ssl/server.conf new file mode 100644 index 00000000..f2611a33 --- /dev/null +++ b/src/test/resources/ssl/server.conf @@ -0,0 +1,37 @@ +[ default ] +ca = testca +base_url = http://example.com +aia_url = $base_url/$ca.cer +crl_url = $base_url/$ca.crl +name_opt = multiline,-esc_msb,utf8 + +[ req ] +default_bits = 2048 +encrypt_key = no +default_md = sha256 +utf8 = yes +string_mask = utf8only +prompt = yes +distinguished_name = server_dn +req_extensions = server_reqext + +[ server_dn ] +organizationName = "Example" +organizationalUnitName = "Example Cluster" +countryName = "SE" + +[ server_reqext ] +keyUsage = critical,digitalSignature,keyEncipherment +extendedKeyUsage = critical,serverAuth,clientAuth +subjectKeyIdentifier = hash +subjectAltName = @alt_names + +[ csr_reqext ] +keyUsage = critical,digitalSignature,keyEncipherment +extendedKeyUsage = critical,serverAuth,clientAuth +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always +subjectAltName = @alt_names + +[ alt_names ] +DNS.0 = localhost