From a096220ece263e8ab679a2c2c1933fed8e4bbc60 Mon Sep 17 00:00:00 2001 From: Nikolai Prokoschenko Date: Mon, 22 Apr 2024 13:47:13 +0200 Subject: [PATCH] Rework CA certificate support to allow rootless containers This patch includes several improvements and simplifications in CA certificate handling: * Support for CA certificates in containers running as a non-root user * Support for CA certificates in containers running with read-only filesystem * Unification of Docker entrypoint scripts into one * Entrypoint script now exports CACERT environment variable to point to the used truststore file Docs updates at https://github.com/docker-library/official-images/ pending. Possibly fixes: https://github.com/adoptium/containers/issues/464 --- .../certs/README.md | 2 +- .../certs/{server.crt => dockerbuilder.crt} | 0 .../certs/{server.key => dockerbuilder.key} | 0 .../expected-std-out.txt | 2 +- .../tests/java-ca-certificates-update/run.sh | 105 +++++++++++------- docker_templates/entrypoint.sh | 89 +++++++++++++++ .../scripts/entrypoint.alpine-linux.sh | 30 ----- docker_templates/scripts/entrypoint.centos.sh | 1 - .../scripts/entrypoint.ubi9-minimal.sh | 30 ----- docker_templates/scripts/entrypoint.ubuntu.sh | 30 ----- generate_dockerfiles.py | 18 +-- 11 files changed, 164 insertions(+), 143 deletions(-) rename .test/tests/java-ca-certificates-update/certs/{server.crt => dockerbuilder.crt} (100%) rename .test/tests/java-ca-certificates-update/certs/{server.key => dockerbuilder.key} (100%) create mode 100755 docker_templates/entrypoint.sh delete mode 100755 docker_templates/scripts/entrypoint.alpine-linux.sh delete mode 120000 docker_templates/scripts/entrypoint.centos.sh delete mode 100755 docker_templates/scripts/entrypoint.ubi9-minimal.sh delete mode 100755 docker_templates/scripts/entrypoint.ubuntu.sh diff --git a/.test/tests/java-ca-certificates-update/certs/README.md b/.test/tests/java-ca-certificates-update/certs/README.md index b60d1d3a1..b408dfed4 100644 --- a/.test/tests/java-ca-certificates-update/certs/README.md +++ b/.test/tests/java-ca-certificates-update/certs/README.md @@ -1 +1 @@ -This certificate/key pair has been generated with `openssl req -nodes -new -x509 -days 358000 -subj "/DC=Temurin/CN=DockerBuilder" -keyout certs/server.key -out certs/server.crt` and is only used for testing +This certificate/key pair has been generated with `openssl req -nodes -new -x509 -days 358000 -subj "/DC=Temurin/CN=DockerBuilder" -keyout certs/dockerbuilder.key -out certs/dockerbuilder.crt` and is only used for testing diff --git a/.test/tests/java-ca-certificates-update/certs/server.crt b/.test/tests/java-ca-certificates-update/certs/dockerbuilder.crt similarity index 100% rename from .test/tests/java-ca-certificates-update/certs/server.crt rename to .test/tests/java-ca-certificates-update/certs/dockerbuilder.crt diff --git a/.test/tests/java-ca-certificates-update/certs/server.key b/.test/tests/java-ca-certificates-update/certs/dockerbuilder.key similarity index 100% rename from .test/tests/java-ca-certificates-update/certs/server.key rename to .test/tests/java-ca-certificates-update/certs/dockerbuilder.key diff --git a/.test/tests/java-ca-certificates-update/expected-std-out.txt b/.test/tests/java-ca-certificates-update/expected-std-out.txt index dc1b0ce46..055b504d1 100644 --- a/.test/tests/java-ca-certificates-update/expected-std-out.txt +++ b/.test/tests/java-ca-certificates-update/expected-std-out.txt @@ -1 +1 @@ -0101010001 +01010100010101010001 diff --git a/.test/tests/java-ca-certificates-update/run.sh b/.test/tests/java-ca-certificates-update/run.sh index 84a586c4f..fe41fed60 100755 --- a/.test/tests/java-ca-certificates-update/run.sh +++ b/.test/tests/java-ca-certificates-update/run.sh @@ -1,40 +1,38 @@ #!/bin/bash -set -o pipefail +set -o pipefail testDir="$(readlink -f "$(dirname "$BASH_SOURCE")")" runDir="$(dirname "$(readlink -f "$BASH_SOURCE")")" -# Find Java major/minor/build/patch version -# -# https://stackoverflow.com/a/74459237/6460 -IFS='"' read -r _ java_version_string _ < <(docker run "$1" java -version 2>&1) -IFS='._' read -r \ - java_version_major \ - java_version_minor \ - java_version_build \ - java_version_patch \ - <<<"$java_version_string" - # CMD1 in each run is just a `date` to make sure nothing is broken with or without the entrypoint CMD1=date -# CMD2 in each run is to check for the `dockerbuilder` certificate in the Java keystore -if [ "$java_version_major" -lt 11 ]; then - # We are working with JDK/JRE 8 - # - # `keytool` from JDK/JRE 8 does not have the `-cacerts` option and also does not have standardized location for the - # `cacerts` file between the JDK and JRE, so we'd want to check both possible locations. - CACERTS=/opt/java/openjdk/lib/security/cacerts - CACERTS2=/opt/java/openjdk/jre/lib/security/cacerts - - CMD2=(sh -c "keytool -list -keystore $CACERTS -storepass changeit -alias dockerbuilder || keytool -list -keystore $CACERTS2 -storepass changeit -alias dockerbuilder") -else - CMD2=(keytool -list -cacerts -storepass changeit -alias dockerbuilder) -fi - -# -# We need to use `docker run`, since `run-in-container.sh` overwrites the entrypoint +# CMD2 in each run is to check for the `dockerbuilder` certificate in the Java keystore. Entrypoint export $CACERT to +# point to the Java keystore. +CMD2=(sh -c "keytool -list -keystore \$CACERT -storepass changeit -alias dockerbuilder") + +# For a custom entrypoint test, we need to create a new image. This image will get cleaned up at the end of the script +# by the `finish` trap function. +TESTIMAGE=$1.test + +function finish { + docker rmi "$TESTIMAGE" >&/dev/null +} +trap finish EXIT HUP INT TERM + +# But first, we need to create an image with an overridden entrypoint +docker build -t "$1.test" "$runDir" -f - <&/dev/null +FROM $1 +COPY custom-entrypoint.sh / +ENTRYPOINT ["/custom-entrypoint.sh"] +EOF + +# NB: In this script, we need to use `docker run` explicitely, since the normally used `run-in-container.sh` overwrites +# the entrypoint. + +# +# PHASE 1: Root containers # # Test run 1: No added certificates and environment variable is not set. We expect CMD1 to succeed and CMD2 to fail. @@ -63,24 +61,47 @@ echo -n $? docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null echo -n $? -TESTIMAGE=$1.test +# Test run 5: Certificates are mounted and the environment variable is set, but the entrypoint is overridden. We expect +# CMD1 to succeed and CMD2 to fail. +docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null +echo -n $? +docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null +echo -n $? -function finish { - docker rmi "$TESTIMAGE" >&/dev/null -} -trap finish EXIT HUP INT TERM +# +# PHASE 2: Non-root containers +# + +# Test run 1: No added certificates and environment variable is not set. We expect CMD1 to succeed and CMD2 to fail. +docker run --read-only --user 1000:1000 --rm "$1" $CMD1 >&/dev/null +echo -n $? +docker run --read-only --user 1000:1000 --rm "$1" "${CMD2[@]}" >&/dev/null +echo -n $? + +# Test run 2: No added certificates, but the environment variable is set. Since there are no certificates, we still +# expect CMD1 to succeed and CMD2 to fail. +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 "$1" $CMD1 >&/dev/null +echo -n $? +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 "$1" "${CMD2[@]}" >&/dev/null +echo -n $? + +# Test run 3: Certificates are mounted, but the environment variable is not set, i.e. certificate importing should not +# be activated. We expect CMD1 to succeed and CMD2 to fail. +docker run --read-only --user 1000:1000 --rm --volume=$testDir/certs:/certificates "$1" $CMD1 >&/dev/null +echo -n $? +docker run --read-only --user 1000:1000 --rm --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null +echo -n $? + +# Test run 4: Certificates are mounted and the environment variable is set. We expect both CMD1 and CMD2 to succeed. +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" $CMD1 >&/dev/null +echo -n $? +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null +echo -n $? # Test run 5: Certificates are mounted and the environment variable is set, but the entrypoint is overridden. We expect # CMD1 to succeed and CMD2 to fail. # -# But first, we need to create an image with an overridden entrypoint -docker build -t "$1.test" "$runDir" -f - <&/dev/null -FROM $1 -COPY custom-entrypoint.sh / -ENTRYPOINT ["/custom-entrypoint.sh"] -EOF - -docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null echo -n $? -docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null +docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null echo -n $? diff --git a/docker_templates/entrypoint.sh b/docker_templates/entrypoint.sh new file mode 100755 index 000000000..f2673a7bc --- /dev/null +++ b/docker_templates/entrypoint.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env sh +# Converted to POSIX shell to avoid the need for bash in the image + +set -e + +# JDK truststore location +CACERT=$JAVA_HOME/lib/security/cacerts + +# JDK8 puts its JRE in a subdirectory +if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then + CACERT=$JAVA_HOME/jre/lib/security/cacerts +fi + +# Opt-in is only activated if the environment variable is set +if [ -n "$USE_SYSTEM_CA_CERTS" ]; then + + if [ ! -w /tmp ]; then + echo "Using additional CA certificates requires write permissions to /tmp. Cannot create truststore." + exit 1 + fi + + # Figure out whether we can write to the JVM truststore. If we can, we'll add the certificates there. If not, + # we'll use a temporary truststore. + if [ ! -w "$CACERT" ]; then + # We cannot write to the JVM truststore, so we create a temporary one + CACERT_NEW=$(mktemp) + echo "Using a temporary truststore at $CACERT_NEW" + cp $CACERT $CACERT_NEW + CACERT=$CACERT_NEW + # If we use a custom truststore, we need to make sure that the JVM uses it + export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS} -Djavax.net.ssl.trustStore=${CACERT} -Djavax.net.ssl.trustStorePassword=changeit" + fi + + tmp_store=$(mktemp) + + # Copy full system CA store to a temporary location + trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$tmp_store" + + # Add the system CA certificates to the JVM truststore. + keytool -importkeystore -destkeystore "$CACERT" -srckeystore "$tmp_store" -srcstorepass changeit -deststorepass changeit -noprompt # >/dev/null + + # Import the additional certificate into JVM truststore + for i in /certificates/*crt; do + if [ ! -f "$i" ]; then + continue + fi + keytool -import -noprompt -alias "$(basename "$i" .crt)" -file "$i" -keystore "$CACERT" -storepass changeit # >/dev/null + done + + # Add additionation certificates to the system CA store. This requires write permissions to several system + # locations, which is not possible in a container with read-only filesystem and/or non-root container. + if [ "$(id -u)" -eq 0 ]; then + + # Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty. + # The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the + # system location, for whatever reason. + if [ -d /certificates ] && [ "$(ls -A /certificates 2>/dev/null)" ]; then + + # UBI/CentOS + if [ -d /usr/share/pki/ca-trust-source/anchors/ ]; then + cp -a /certificates/* /usr/share/pki/ca-trust-source/anchors/ + fi + + # Ubuntu/Alpine + if [ -d /usr/local/share/ca-certificates/ ]; then + cp -a /certificates/* /usr/local/share/ca-certificates/ + fi + fi + + # UBI/CentOS + if which update-ca-trust >/dev/null; then + update-ca-trust + fi + + # Ubuntu/Alpine + if which update-ca-certificates >/dev/null; then + update-ca-certificates + fi + else + # If we are not root, we cannot update the system truststore. That's bad news for tools like `curl` and `wget`, + # but since the JVM is the primary focus here, we can live with that. + true + fi +fi + +# Let's provide a variable with the correct path for tools that want or need to use it +export CACERT + +exec "$@" diff --git a/docker_templates/scripts/entrypoint.alpine-linux.sh b/docker_templates/scripts/entrypoint.alpine-linux.sh deleted file mode 100755 index 029cade7e..000000000 --- a/docker_templates/scripts/entrypoint.alpine-linux.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env sh -# Converted to POSIX shell to avoid the need for bash in the image - -set -e - -# Opt-in is only activated if the environment variable is set -if [ -n "$USE_SYSTEM_CA_CERTS" ]; then - - # Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty. - # The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the - # system location, for whatever reason. - if [ -d /certificates ] && [ -n "$(ls -A /certificates 2>/dev/null)" ]; then - cp -a /certificates/* /usr/local/share/ca-certificates/ - fi - - CACERT="$JAVA_HOME/lib/security/cacerts" - - # JDK8 puts its JRE in a subdirectory - if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then - CACERT="$JAVA_HOME/jre/lib/security/cacerts" - fi - - # OpenJDK images used to create a hook for `update-ca-certificates`. Since we are using an entrypoint anyway, we - # might as well just generate the truststore and skip the hooks. - update-ca-certificates - - trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$CACERT" -fi - -exec "$@" diff --git a/docker_templates/scripts/entrypoint.centos.sh b/docker_templates/scripts/entrypoint.centos.sh deleted file mode 120000 index 562528611..000000000 --- a/docker_templates/scripts/entrypoint.centos.sh +++ /dev/null @@ -1 +0,0 @@ -entrypoint.ubi9-minimal.sh \ No newline at end of file diff --git a/docker_templates/scripts/entrypoint.ubi9-minimal.sh b/docker_templates/scripts/entrypoint.ubi9-minimal.sh deleted file mode 100755 index 481ab8862..000000000 --- a/docker_templates/scripts/entrypoint.ubi9-minimal.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Shebang needs to be `bash`, see https://github.com/adoptium/containers/issues/415 for details - -set -e - -# Opt-in is only activated if the environment variable is set -if [ -n "$USE_SYSTEM_CA_CERTS" ]; then - - # Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty. - # The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the - # system location, for whatever reason. - if [ -d /certificates ] && [ "$(ls -A /certificates)" ]; then - cp -a /certificates/* /usr/share/pki/ca-trust-source/anchors/ - fi - - CACERT=$JAVA_HOME/lib/security/cacerts - - # JDK8 puts its JRE in a subdirectory - if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then - CACERT=$JAVA_HOME/jre/lib/security/cacerts - fi - - # RHEL-based images already include a routine to update a java truststore from the system CA bundle within - # `update-ca-trust`. All we need to do is to link the system CA bundle to the java truststore. - update-ca-trust - - ln -sf /etc/pki/ca-trust/extracted/java/cacerts "$CACERT" -fi - -exec "$@" diff --git a/docker_templates/scripts/entrypoint.ubuntu.sh b/docker_templates/scripts/entrypoint.ubuntu.sh deleted file mode 100755 index dfcf546f9..000000000 --- a/docker_templates/scripts/entrypoint.ubuntu.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Sheband needs to be `bash`, see https://github.com/adoptium/containers/issues/415 for details - -set -e - -# Opt-in is only activated if the environment variable is set -if [ -n "$USE_SYSTEM_CA_CERTS" ]; then - - # Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty. - # The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the - # system location, for whatever reason. - if [ -d /certificates ] && [ "$(ls -A /certificates)" ]; then - cp -a /certificates/* /usr/local/share/ca-certificates/ - fi - - CACERT=$JAVA_HOME/lib/security/cacerts - - # JDK8 puts its JRE in a subdirectory - if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then - CACERT=$JAVA_HOME/jre/lib/security/cacerts - fi - - # OpenJDK images used to create a hook for `update-ca-certificates`. Since we are using an entrypoint anyway, we - # might as well just generate the truststore and skip the hooks. - update-ca-certificates - - trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$CACERT" -fi - -exec "$@" diff --git a/generate_dockerfiles.py b/generate_dockerfiles.py index 8865a78a4..53cf5e788 100644 --- a/generate_dockerfiles.py +++ b/generate_dockerfiles.py @@ -151,14 +151,16 @@ def archHelper(arch, os_family): ) as out_file: out_file.write(rendered_dockerfile) - # Copy entrypoint.sh to output directory - entrypoint_path = os.path.join( - "docker_templates", "scripts", f"entrypoint.{os_name}.sh" - ) + if os_family != "windows": + # Entrypoint is currently only needed for CA certificate handling, which is not (yet) + # available on Windows + + # Copy entrypoint.sh to output directory + entrypoint_path = os.path.join("docker_templates", "entrypoint.sh") - if os.path.exists(entrypoint_path): - os.system( - f"cp {entrypoint_path} {os.path.join(output_directory, 'entrypoint.sh')}" - ) + if os.path.exists(entrypoint_path): + os.system( + f"cp {entrypoint_path} {os.path.join(output_directory, 'entrypoint.sh')}" + ) print("Dockerfiles generated successfully!")