Skip to content

Commit

Permalink
Rework CA certificate support to allow rootless containers
Browse files Browse the repository at this point in the history
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: #464
  • Loading branch information
rassie committed Apr 23, 2024
1 parent 057e5aa commit 469060b
Show file tree
Hide file tree
Showing 11 changed files with 164 additions and 143 deletions.
2 changes: 1 addition & 1 deletion .test/tests/java-ca-certificates-update/certs/README.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0101010001
01010100010101010001
105 changes: 63 additions & 42 deletions .test/tests/java-ca-certificates-update/run.sh
Original file line number Diff line number Diff line change
@@ -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 - <<EOF >&/dev/null
FROM $1
COPY custom-entrypoint.sh /
ENTRYPOINT ["/custom-entrypoint.sh"]
EOF

# NB: In this script, we need to use `docker run` explicitly, 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.
Expand Down Expand Up @@ -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 - <<EOF >&/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 $?
89 changes: 89 additions & 0 deletions docker_templates/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 additional 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 "$@"
30 changes: 0 additions & 30 deletions docker_templates/scripts/entrypoint.alpine-linux.sh

This file was deleted.

1 change: 0 additions & 1 deletion docker_templates/scripts/entrypoint.centos.sh

This file was deleted.

30 changes: 0 additions & 30 deletions docker_templates/scripts/entrypoint.ubi9-minimal.sh

This file was deleted.

30 changes: 0 additions & 30 deletions docker_templates/scripts/entrypoint.ubuntu.sh

This file was deleted.

18 changes: 10 additions & 8 deletions generate_dockerfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!")

0 comments on commit 469060b

Please sign in to comment.