From 607cf4f0f67781cc5a942c88a66218ebfecfc21b Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 12:41:21 +0300 Subject: [PATCH 1/4] [#1087] Copy the secret volume on every start of the Docker image, and run the server as PID 1 after the bootstrap Keystores and truststores from SECRET_VOLUME were copied only on the first start, and only after setup had already started the server, so the mounted certificate was served from the first restart on and a renewed one never reached an instance on a persistent volume. They are now copied before every start, each file renamed over its target with mode 0600, and checked again every SECRET_VOLUME_REFRESH seconds while the server runs. On the first start the copy runs once run.sh has stopped the server setup started (#1098), right before the exec that makes the server PID 1, so the mounted certificate is served from the first start on. The README documents the volume and a cert-manager setup that mounts its PKCS12 keystore under the names the image expects. Fixes #1087 --- opendj-packages/opendj-docker/Dockerfile | 3 +- .../opendj-docker/Dockerfile-alpine | 3 +- opendj-packages/opendj-docker/README.md | 57 +++++++++++++++- opendj-packages/opendj-docker/run.sh | 68 +++++++++++++++---- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/opendj-packages/opendj-docker/Dockerfile b/opendj-packages/opendj-docker/Dockerfile index e84ce54f08..fd1f044335 100644 --- a/opendj-packages/opendj-docker/Dockerfile +++ b/opendj-packages/opendj-docker/Dockerfile @@ -24,7 +24,8 @@ ENV BASE_DN="dc=example,dc=com" ENV ROOT_USER_DN="cn=Directory Manager" # ROOT_PASSWORD should be passed at runtime via: docker run -e ROOT_PASSWORD=... # Default value if not provided: "password" -#ENV SECRET_VOLUME +#ENV SECRET_VOLUME="/var/secrets/opendj" +#ENV SECRET_VOLUME_REFRESH=60 ENV OPENDJ_SSL_OPTIONS="--generateSelfSignedCertificate" #ENV MASTER_SERVER #ENV OPENDJ_REPLICATION_TYPE diff --git a/opendj-packages/opendj-docker/Dockerfile-alpine b/opendj-packages/opendj-docker/Dockerfile-alpine index bb47092286..70942f7747 100644 --- a/opendj-packages/opendj-docker/Dockerfile-alpine +++ b/opendj-packages/opendj-docker/Dockerfile-alpine @@ -24,7 +24,8 @@ ENV BASE_DN="dc=example,dc=com" ENV ROOT_USER_DN="cn=Directory Manager" # ROOT_PASSWORD should be passed at runtime via: docker run -e ROOT_PASSWORD=... # Default value if not provided: "password" -#ENV SECRET_VOLUME +#ENV SECRET_VOLUME="/var/secrets/opendj" +#ENV SECRET_VOLUME_REFRESH=60 ENV OPENDJ_SSL_OPTIONS="--generateSelfSignedCertificate" #ENV MASTER_SERVER #ENV OPENDJ_REPLICATION_TYPE diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index 42ee9a98db..4a7342f33a 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -47,6 +47,60 @@ over an instance that is already there - never reports healthy: what failed is i logs`, and where the server is up at all the container is left running to be looked at, turning `unhealthy` once the start period is over. +## Certificates + +With the default `OPENDJ_SSL_OPTIONS` the instance serves LDAPS and StartTLS with a +self-signed certificate from `config/keystore`, whose password is in `config/keystore.pin`. +To serve your own certificate, mount a directory holding a `keystore` (JKS or PKCS12) and +its `keystore.pin` at `SECRET_VOLUME`, and a `truststore` next to them if clients present +certificates: + +```bash +docker run -d --name opendj -v opendj-data:/opt/opendj/data \ + -v "$PWD/secrets":/var/secrets/opendj:ro openidentityplatform/opendj +``` + +Every `key*` and `trust*` file of that directory is copied into `/opt/opendj/data/config` +before the server starts - on the first start and on every later one, so a certificate +renewed on the volume reaches an instance kept on a persistent volume. While the server +runs, the directory is checked again every `SECRET_VOLUME_REFRESH` seconds and a changed +file is copied again. The server reads the copied keystore when it starts, so a certificate +renewed while it runs is served from its next restart. The administration connector and +replication keep keys of their own and are not affected. + +On Kubernetes, the PEM files of a `kubernetes.io/tls` Secret cannot be used as they are: +OpenDJ reads keystores, not PEM. cert-manager can add a PKCS12 keystore to the Secret it +issues, and a projected volume mounts it under the names above: + +```yaml +# the Certificate +spec: + secretName: opendj-tls + keystores: + pkcs12: + create: true + passwordSecretRef: { name: opendj-keystore-password, key: password } +--- +# the pod template of the StatefulSet +volumes: + - name: secrets + projected: + sources: + - secret: + name: opendj-tls + items: [{ key: keystore.p12, path: keystore }] + - secret: + name: opendj-keystore-password + items: [{ key: password, path: keystore.pin }] +containers: + - name: opendj + volumeMounts: + - { name: secrets, mountPath: /var/secrets/opendj, readOnly: true } +``` + +Mount the volume as a whole, not with `subPath`: Kubernetes does not update files mounted +with `subPath` when the Secret changes. + ## Environment Variables | Variable | Default Value | Description | @@ -58,7 +112,8 @@ turning `unhealthy` once the start period is over. | BASE_DN | dc=example,dc=com | OpenDJ Base DN | | ROOT_USER_DN | cn=Directory Manager | Initial root user DN | | ROOT_PASSWORD | password | Initial root user password; the bootstrap fails if it contains a line break (CR or LF) | -| SECRET_VOLUME | - | Mounted keystore volume, if present copies keystore over | +| SECRET_VOLUME | /var/secrets/opendj | Mounted keystore volume, if present its `key*` and `trust*` files are copied into the instance on every start, see [Certificates](#certificates) | +| SECRET_VOLUME_REFRESH | 60 | While the server runs, `SECRET_VOLUME` is checked again every that many seconds and changed files are copied again; `0` copies them on start only | | MASTER_SERVER | - | Replication master server | | VERSION | - | OpenDJ version | | OPENDJ_USER | opendj | user which runs OpenDJ | diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index 08a1bf7b69..d7eb45b86c 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -45,6 +45,54 @@ rm -f "$BOOTSTRAP_COMPLETE" rm -f /dev/shm/opendj-replicate."$ADMIN_PORT".* rm -f /dev/shm/opendj-setup-password."$ADMIN_PORT".* /tmp/opendj-setup-password.* +# Keystores and truststores mounted as a volume (a Kubernetes Secret, say) are copied into +# the instance on every start, not only on the one that bootstraps it: the instance lives on +# a persistent volume, and a renewed certificate in the Secret has to reach it. +SECRET_VOLUME=${SECRET_VOLUME:-/var/secrets/opendj} +# Kubernetes updates a mounted Secret in place, so while the server runs the volume is +# checked again every that many seconds; 0 checks it on start only +SECRET_VOLUME_REFRESH=${SECRET_VOLUME_REFRESH:-60} + +# Copies the key* and trust* files of the secret volume that differ from those in +# ./data/config. Each one is written next to its target and renamed over it, so the server +# never reads a file half copied, and it is readable by the server's user only: a keystore +# holds the private key, a .pin file its password. +copy_secrets() { + local src dst tmp + [ -d "$SECRET_VOLUME" ] || return 0 + for src in "$SECRET_VOLUME"/key* "$SECRET_VOLUME"/trust*; do + [ -f "$src" ] || continue + dst=./data/config/$(basename -- "$src") + cmp -s "$src" "$dst" && continue + if tmp=$(mktemp "$dst.XXXXXX") && cp "$src" "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$dst"; then + echo "Copied $(basename -- "$src") from the secret volume" + else + rm -f "$tmp" + echo "Could not copy $(basename -- "$src") from the secret volume" + fi + done +} + +watch_secrets() { + while sleep "$SECRET_VOLUME_REFRESH"; do + copy_secrets + done +} + +# Both kinds of start end here, with the server as PID 1 of the container: that is the +# process the container runtime sends SIGTERM to, and the server stops cleanly on it. +start_server() { + if [ -d "$SECRET_VOLUME" ]; then + echo "Secret volume is present. Will copy any keystores and truststore" + copy_secrets + if [ "$SECRET_VOLUME_REFRESH" -gt 0 ] 2>/dev/null; then + watch_secrets & + fi + fi + echo "Starting OpenDJ" + exec ./bin/start-ds --nodetach +} + #if default data folder exists do not change it if [ ! -d ./db ]; then echo "/opt/opendj/data" >/opt/opendj/instance.loc && \ @@ -60,8 +108,7 @@ if [ -d ./data/config ]; then else echo "Upgrade failed, this container will not report itself healthy" fi - exec ./bin/start-ds --nodetach - exit + start_server fi # If we are here, opendj is not installed & we need to run setup @@ -92,7 +139,9 @@ fi # started again below with exec, so that the server is PID 1 on the first start just as # on a restart: a shell as PID 1 without a SIGTERM handler never receives the signal, and # the container would then be killed at the end of the stop timeout instead of stopping -# the server. It is stopped before the marker below is written, so that the health check +# the server. Left running, it would also keep the certificate it was set up with rather +# than the one on the secret volume, which start_server copies in before it starts the +# server. It is stopped before the marker below is written, so that the health check # never reports the server of the bootstrap healthy just before it goes down. stop-ds # exits 0 when the server is not running. When it fails the server may still be stopping, # so the start below is tried anyway: it either runs the server or fails on the lock of @@ -102,21 +151,10 @@ if ! ./bin/stop-ds; then echo "Could not stop the server started by the bootstrap, starting OpenDJ may fail" fi -# Check if keystores are mounted as a volume, and if so -# Copy any keystores over -SECRET_VOLUME=${SECRET_VOLUME:-/var/secrets/opendj} - -if [ -d "${SECRET_VOLUME}" ]; then - echo "Secret volume is present. Will copy any keystores and truststore" - # We send errors to /dev/null in case no data exists. - cp -f ${SECRET_VOLUME}/key* ${SECRET_VOLUME}/trust* ./data/config 2>/dev/null -fi - # Everything the instance was asked to be set up with - its backend, its base entry, its # replication - is in place from here on, so the health check may start probing the server if [ "$BOOTSTRAPPED" = true ]; then touch "$BOOTSTRAP_COMPLETE" fi -echo "Starting OpenDJ" -exec ./bin/start-ds --nodetach +start_server From 6bd1132d1309f48d65850308bf4487876cfc5df6 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 12:41:38 +0300 Subject: [PATCH 2/4] [#1087] Test the secret volume of the Docker image in CI A step in both docker jobs mounts a keystore at SECRET_VOLUME and checks that LDAPS serves it from the first start, that the server is PID 1 and stops on SIGTERM, that a renewed keystore reaches the data volume while the server runs and is served after a restart, and that the copied PIN file is readable by the server's user only. --- .github/workflows/build.yml | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c54e3277c..b785c98198 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -647,6 +647,44 @@ jobs: done docker exec test_replica test -e /dev/shm/opendj-replicate.5444.other || { echo "::error::run.sh of test_replica removed the password file of another container"; false; } cleanup + - name: Docker test secret volume + # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, + # a renewed one reaches the data volume while the server runs and is served after a + # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085) + shell: bash + env: + IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }} + run: | + trap 'code=$?; echo "::group::container logs (test_secret)"; docker logs test_secret 2>&1 || true; echo "::endgroup::"; exit $code' ERR + SECRETS=$(mktemp -d) + chmod 777 "$SECRETS" + keystore() { + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias server-cert \ + -keyalg RSA -keysize 2048 -validity 30 -dname "CN=$1" -storetype PKCS12 \ + -keystore /secrets/keystore.new -storepass changeit -keypass changeit + mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" + printf changeit > "$SECRETS/keystore.pin" + } + served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject; } + healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } + keystore secret-v1 + docker run -d --memory="512m" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ + -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" + healthy + grep -q "CN=secret-v1" <<< "$(served)" + test "$(docker exec test_secret cat /proc/1/comm)" = java + test "$(docker exec test_secret stat -c %a /opt/opendj/data/config/keystore.pin)" = 600 + keystore secret-v2 + timeout 1m bash -c 'until docker exec test_secret cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore; do sleep 5; done' + start=$SECONDS + docker stop -t 60 test_secret + test $((SECONDS - start)) -lt 30 + test "$(docker inspect --format='{{.State.ExitCode}}' test_secret)" = 143 + docker start test_secret + healthy + grep -q "CN=secret-v2" <<< "$(served)" + docker rm -f test_secret + docker volume rm test_secret_data - name: Docker test bootstrap LDIFs shell: bash run: | @@ -955,6 +993,44 @@ jobs: done docker exec test_replica test -e /dev/shm/opendj-replicate.5444.other || { echo "::error::run.sh of test_replica removed the password file of another container"; false; } cleanup + - name: Docker test secret volume + # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, + # a renewed one reaches the data volume while the server runs and is served after a + # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085) + shell: bash + env: + IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }}-alpine + run: | + trap 'code=$?; echo "::group::container logs (test_secret)"; docker logs test_secret 2>&1 || true; echo "::endgroup::"; exit $code' ERR + SECRETS=$(mktemp -d) + chmod 777 "$SECRETS" + keystore() { + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias server-cert \ + -keyalg RSA -keysize 2048 -validity 30 -dname "CN=$1" -storetype PKCS12 \ + -keystore /secrets/keystore.new -storepass changeit -keypass changeit + mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" + printf changeit > "$SECRETS/keystore.pin" + } + served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject; } + healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } + keystore secret-v1 + docker run -d --memory="1g" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ + -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" + healthy + grep -q "CN=secret-v1" <<< "$(served)" + test "$(docker exec test_secret cat /proc/1/comm)" = java + test "$(docker exec test_secret stat -c %a /opt/opendj/data/config/keystore.pin)" = 600 + keystore secret-v2 + timeout 1m bash -c 'until docker exec test_secret cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore; do sleep 5; done' + start=$SECONDS + docker stop -t 60 test_secret + test $((SECONDS - start)) -lt 30 + test "$(docker inspect --format='{{.State.ExitCode}}' test_secret)" = 143 + docker start test_secret + healthy + grep -q "CN=secret-v2" <<< "$(served)" + docker rm -f test_secret + docker volume rm test_secret_data - name: Docker test bootstrap LDIFs shell: bash run: | From fbf50a23733f9790cd06efeccf0f9d040dd33054 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 12:18:05 +0300 Subject: [PATCH 3/4] [#1087] Test the copy on the restart road and with the watcher off, and read the served subject in one format The runner's OpenSSL 3.0.13 prints the subject as `CN = secret-v1`, so the secret-volume step now asks for `-nameopt RFC2253`. The step also renews the keystore while no container runs and checks that a new container on the same data volume, with SECRET_VOLUME_REFRESH=0, serves it, and that with 0 a keystore renewed while the server runs is not copied. run.sh repeats the copy until a pass finds nothing left to copy, so that a Secret updated in the middle of a pass cannot leave a keystore of one version next to the password of the other, and it says so when SECRET_VOLUME_REFRESH is not a whole number of seconds above 0. The README says that the key may have any alias with the default options, that a new keystore password takes effect on the next restart, and recommends `docker run --init`. --- .github/workflows/build.yml | 32 +++++++++++++++++++++---- opendj-packages/opendj-docker/README.md | 12 ++++++++-- opendj-packages/opendj-docker/run.sh | 22 +++++++++++++---- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b785c98198..dcbea90841 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -650,7 +650,9 @@ jobs: - name: Docker test secret volume # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, # a renewed one reaches the data volume while the server runs and is served after a - # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085) + # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085); + # one renewed while no container ran is copied by the start over the instance already + # there, and SECRET_VOLUME_REFRESH=0 copies nothing while the server runs shell: bash env: IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }} @@ -665,7 +667,7 @@ jobs: mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" printf changeit > "$SECRETS/keystore.pin" } - served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject; } + served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject -nameopt RFC2253; } healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } keystore secret-v1 docker run -d --memory="512m" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ @@ -683,6 +685,16 @@ jobs: docker start test_secret healthy grep -q "CN=secret-v2" <<< "$(served)" + docker stop -t 60 test_secret + docker rm test_secret + keystore secret-v3 + docker run -d --memory="512m" -e SECRET_VOLUME_REFRESH=0 -p 127.0.0.1:1637:1636 --name=test_secret \ + -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" + healthy + grep -q "CN=secret-v3" <<< "$(served)" + keystore secret-v4 + sleep 15 + docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker rm -f test_secret docker volume rm test_secret_data - name: Docker test bootstrap LDIFs @@ -996,7 +1008,9 @@ jobs: - name: Docker test secret volume # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, # a renewed one reaches the data volume while the server runs and is served after a - # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085) + # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085); + # one renewed while no container ran is copied by the start over the instance already + # there, and SECRET_VOLUME_REFRESH=0 copies nothing while the server runs shell: bash env: IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }}-alpine @@ -1011,7 +1025,7 @@ jobs: mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" printf changeit > "$SECRETS/keystore.pin" } - served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject; } + served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject -nameopt RFC2253; } healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } keystore secret-v1 docker run -d --memory="1g" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ @@ -1029,6 +1043,16 @@ jobs: docker start test_secret healthy grep -q "CN=secret-v2" <<< "$(served)" + docker stop -t 60 test_secret + docker rm test_secret + keystore secret-v3 + docker run -d --memory="1g" -e SECRET_VOLUME_REFRESH=0 -p 127.0.0.1:1637:1636 --name=test_secret \ + -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" + healthy + grep -q "CN=secret-v3" <<< "$(served)" + keystore secret-v4 + sleep 15 + docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker rm -f test_secret docker volume rm test_secret_data - name: Docker test bootstrap LDIFs diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index 4a7342f33a..0cd511fe28 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -47,13 +47,20 @@ over an instance that is already there - never reports healthy: what failed is i logs`, and where the server is up at all the container is left running to be looked at, turning `unhealthy` once the start period is over. +The server runs as PID 1 of the container, and a JVM does not reap the processes left +behind to it - those of a health check that ran past its timeout, say. Run the container +with `docker run --init` (`init: true` in Compose) to put a PID 1 in front of the server +that reaps them and passes SIGTERM on to it. + ## Certificates With the default `OPENDJ_SSL_OPTIONS` the instance serves LDAPS and StartTLS with a self-signed certificate from `config/keystore`, whose password is in `config/keystore.pin`. To serve your own certificate, mount a directory holding a `keystore` (JKS or PKCS12) and its `keystore.pin` at `SECRET_VOLUME`, and a `truststore` next to them if clients present -certificates: +certificates. With the default options the connection handlers are not bound to an alias, +so the key entry of the keystore may have any alias; if `OPENDJ_SSL_OPTIONS` sets a +`--certNickname`, the key has to be under that alias: ```bash docker run -d --name opendj -v opendj-data:/opt/opendj/data \ @@ -65,7 +72,8 @@ before the server starts - on the first start and on every later one, so a certi renewed on the volume reaches an instance kept on a persistent volume. While the server runs, the directory is checked again every `SECRET_VOLUME_REFRESH` seconds and a changed file is copied again. The server reads the copied keystore when it starts, so a certificate -renewed while it runs is served from its next restart. The administration connector and +renewed while it runs is served from its next restart. The same holds for a new keystore +password: the server keeps the one it started with. The administration connector and replication keep keys of their own and are not affected. On Kubernetes, the PEM files of a `kubernetes.io/tls` Secret cannot be used as they are: diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index d7eb45b86c..11d24a0089 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -56,26 +56,36 @@ SECRET_VOLUME_REFRESH=${SECRET_VOLUME_REFRESH:-60} # Copies the key* and trust* files of the secret volume that differ from those in # ./data/config. Each one is written next to its target and renamed over it, so the server # never reads a file half copied, and it is readable by the server's user only: a keystore -# holds the private key, a .pin file its password. +# holds the private key, a .pin file its password. Succeeds when it copied a file. copy_secrets() { - local src dst tmp - [ -d "$SECRET_VOLUME" ] || return 0 + local src dst tmp copied=1 + [ -d "$SECRET_VOLUME" ] || return 1 for src in "$SECRET_VOLUME"/key* "$SECRET_VOLUME"/trust*; do [ -f "$src" ] || continue dst=./data/config/$(basename -- "$src") cmp -s "$src" "$dst" && continue if tmp=$(mktemp "$dst.XXXXXX") && cp "$src" "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$dst"; then echo "Copied $(basename -- "$src") from the secret volume" + copied=0 else rm -f "$tmp" echo "Could not copy $(basename -- "$src") from the secret volume" fi done + return $copied +} + +# The files are compared one at a time, so a Secret updated in the middle of a pass can leave +# a keystore of one version next to the .pin of the other. Passes are repeated until one finds +# nothing left to copy, which puts the files of a single version back together. +sync_secrets() { + local passes=0 + while copy_secrets && [ $((passes += 1)) -lt 5 ]; do :; done } watch_secrets() { while sleep "$SECRET_VOLUME_REFRESH"; do - copy_secrets + sync_secrets done } @@ -84,9 +94,11 @@ watch_secrets() { start_server() { if [ -d "$SECRET_VOLUME" ]; then echo "Secret volume is present. Will copy any keystores and truststore" - copy_secrets + sync_secrets if [ "$SECRET_VOLUME_REFRESH" -gt 0 ] 2>/dev/null; then watch_secrets & + elif [ "$SECRET_VOLUME_REFRESH" != 0 ]; then + echo "SECRET_VOLUME_REFRESH=$SECRET_VOLUME_REFRESH is not a whole number of seconds above 0, the secret volume is copied on start only" fi fi echo "Starting OpenDJ" From d872a0975beef0976b1ddcf4ea6dc3ed7a28b924 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 15:35:12 +0300 Subject: [PATCH 4/4] [#1087] Leave a new keystore password to the next start, and pin the truststore, the alias and the order of the bootstrap's stop in CI While the server runs, the watcher no longer copies the .pin files, and it copies nothing while a key*.pin or trust*.pin on the volume differs from the one the server started with. The server keeps the password it started with, and a keystore it can no longer open with it makes the next dsconfig change to the LDAPS handler disable the handler. The next start copies the stores along with their new password. run.sh logs a line once the instance is marked bootstrapped, and takes SECRET_VOLUME_REFRESH only as digits: "60 " no longer passes the guard and fails sleep, and "00" no longer gets the "not a whole number" message. What happens when the server the bootstrap started does not stop is left to #1098: the failure is logged and the start is tried anyway. The README says so, and no longer says that a --certNickname in OPENDJ_SSL_OPTIONS binds the handlers to an alias: with --generateSelfSignedCertificate setup writes no nickname at all, only a keystore of its own given with --certNickname does. The secret-volume step runs with set -E, so its ERR trap prints the container logs for a failure inside its functions too. Each keystore holds its key under an alias of its own and comes with a truststore, and the step checks that the truststore is copied, that the keystore is copied only when it changed, that stop-ds ran before the instance was marked bootstrapped, that a keystore with a new password is left to the next start, and that SECRET_VOLUME_REFRESH=0 runs no watcher. --- .github/workflows/build.yml | 80 ++++++++++++++++++++----- opendj-packages/opendj-docker/README.md | 18 ++++-- opendj-packages/opendj-docker/run.sh | 34 +++++++++-- 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dcbea90841..3328f050ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -651,24 +651,38 @@ jobs: # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, # a renewed one reaches the data volume while the server runs and is served after a # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085); - # one renewed while no container ran is copied by the start over the instance already - # there, and SECRET_VOLUME_REFRESH=0 copies nothing while the server runs + # one with a new password is left by the watcher to the next start, which copies it + # over the instance already there, and SECRET_VOLUME_REFRESH=0 runs no watcher shell: bash env: IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }} run: | + set -E trap 'code=$?; echo "::group::container logs (test_secret)"; docker logs test_secret 2>&1 || true; echo "::endgroup::"; exit $code' ERR SECRETS=$(mktemp -d) chmod 777 "$SECRETS" + # a key under an alias of its own for each version, since setup binds no connection + # handler to an alias, and a truststore holding its certificate; the passwords are + # written first, as the watcher looks at them before it copies anything keystore() { - docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias server-cert \ + local pass=${2:-changeit} + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias "$1" \ -keyalg RSA -keysize 2048 -validity 30 -dname "CN=$1" -storetype PKCS12 \ - -keystore /secrets/keystore.new -storepass changeit -keypass changeit + -keystore /secrets/keystore.new -storepass "$pass" -keypass "$pass" + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -exportcert -rfc -alias "$1" \ + -keystore /secrets/keystore.new -storepass "$pass" -file /secrets/cert.pem + rm -f "$SECRETS/truststore.new" + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -importcert -noprompt -alias "$1" \ + -file /secrets/cert.pem -keystore /secrets/truststore.new -storetype JKS -storepass changeit + rm -f "$SECRETS/cert.pem" + printf %s "$pass" > "$SECRETS/keystore.pin" + printf changeit > "$SECRETS/truststore.pin" + mv -f "$SECRETS/truststore.new" "$SECRETS/truststore" mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" - printf changeit > "$SECRETS/keystore.pin" } served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject -nameopt RFC2253; } healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } + copies() { docker logs test_secret 2>&1 | grep -c "^Copied $1 from the secret volume$" || true; } keystore secret-v1 docker run -d --memory="512m" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" @@ -676,8 +690,16 @@ jobs: grep -q "CN=secret-v1" <<< "$(served)" test "$(docker exec test_secret cat /proc/1/comm)" = java test "$(docker exec test_secret stat -c %a /opt/opendj/data/config/keystore.pin)" = 600 + docker exec test_secret cmp -s /var/secrets/opendj/truststore /opt/opendj/data/config/truststore + # the bootstrap's server is stopped before the instance is marked bootstrapped + test "$(docker logs test_secret 2>&1 | grep -e '^Stopping Server' -e '^The instance is bootstrapped' | paste -sd '|' -)" \ + = "Stopping Server...|The instance is bootstrapped, the health check may probe it" keystore secret-v2 timeout 1m bash -c 'until docker exec test_secret cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore; do sleep 5; done' + # the watcher has looked at the volume every 5 s since the start, and copied the + # keystore only when it changed: on the start and once more for v2 + sleep 6 + test "$(copies keystore)" = 2 start=$SECONDS docker stop -t 60 test_secret test $((SECONDS - start)) -lt 30 @@ -685,14 +707,18 @@ jobs: docker start test_secret healthy grep -q "CN=secret-v2" <<< "$(served)" + keystore secret-v3 changeit2 + sleep 12 + docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker stop -t 60 test_secret docker rm test_secret - keystore secret-v3 docker run -d --memory="512m" -e SECRET_VOLUME_REFRESH=0 -p 127.0.0.1:1637:1636 --name=test_secret \ -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" healthy grep -q "CN=secret-v3" <<< "$(served)" - keystore secret-v4 + # no watcher, whose sleep is nearly always there, and nothing copied while the server runs + docker exec test_secret sh -c '! grep -sqx sleep /proc/[0-9]*/comm' + keystore secret-v4 changeit2 sleep 15 docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker rm -f test_secret @@ -1009,24 +1035,38 @@ jobs: # a keystore mounted at SECRET_VOLUME is what LDAPS serves from the first start on, # a renewed one reaches the data volume while the server runs and is served after a # restart, and the server is PID 1 of the container, stopping on SIGTERM (#1087, #1085); - # one renewed while no container ran is copied by the start over the instance already - # there, and SECRET_VOLUME_REFRESH=0 copies nothing while the server runs + # one with a new password is left by the watcher to the next start, which copies it + # over the instance already there, and SECRET_VOLUME_REFRESH=0 runs no watcher shell: bash env: IMAGE: localhost:5000/${{ env.image_repository }}:${{ env.release_version }}-alpine run: | + set -E trap 'code=$?; echo "::group::container logs (test_secret)"; docker logs test_secret 2>&1 || true; echo "::endgroup::"; exit $code' ERR SECRETS=$(mktemp -d) chmod 777 "$SECRETS" + # a key under an alias of its own for each version, since setup binds no connection + # handler to an alias, and a truststore holding its certificate; the passwords are + # written first, as the watcher looks at them before it copies anything keystore() { - docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias server-cert \ + local pass=${2:-changeit} + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -genkeypair -alias "$1" \ -keyalg RSA -keysize 2048 -validity 30 -dname "CN=$1" -storetype PKCS12 \ - -keystore /secrets/keystore.new -storepass changeit -keypass changeit + -keystore /secrets/keystore.new -storepass "$pass" -keypass "$pass" + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -exportcert -rfc -alias "$1" \ + -keystore /secrets/keystore.new -storepass "$pass" -file /secrets/cert.pem + rm -f "$SECRETS/truststore.new" + docker run --rm --entrypoint keytool -v "$SECRETS":/secrets "$IMAGE" -importcert -noprompt -alias "$1" \ + -file /secrets/cert.pem -keystore /secrets/truststore.new -storetype JKS -storepass changeit + rm -f "$SECRETS/cert.pem" + printf %s "$pass" > "$SECRETS/keystore.pin" + printf changeit > "$SECRETS/truststore.pin" + mv -f "$SECRETS/truststore.new" "$SECRETS/truststore" mv -f "$SECRETS/keystore.new" "$SECRETS/keystore" - printf changeit > "$SECRETS/keystore.pin" } served() { echo | openssl s_client -connect 127.0.0.1:1637 2>/dev/null | openssl x509 -noout -subject -nameopt RFC2253; } healthy() { timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_secret | grep -q \"healthy\"; do sleep 10; done'; } + copies() { docker logs test_secret 2>&1 | grep -c "^Copied $1 from the secret volume$" || true; } keystore secret-v1 docker run -d --memory="1g" -e SECRET_VOLUME_REFRESH=5 -p 127.0.0.1:1637:1636 --name=test_secret \ -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" @@ -1034,8 +1074,16 @@ jobs: grep -q "CN=secret-v1" <<< "$(served)" test "$(docker exec test_secret cat /proc/1/comm)" = java test "$(docker exec test_secret stat -c %a /opt/opendj/data/config/keystore.pin)" = 600 + docker exec test_secret cmp -s /var/secrets/opendj/truststore /opt/opendj/data/config/truststore + # the bootstrap's server is stopped before the instance is marked bootstrapped + test "$(docker logs test_secret 2>&1 | grep -e '^Stopping Server' -e '^The instance is bootstrapped' | paste -sd '|' -)" \ + = "Stopping Server...|The instance is bootstrapped, the health check may probe it" keystore secret-v2 timeout 1m bash -c 'until docker exec test_secret cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore; do sleep 5; done' + # the watcher has looked at the volume every 5 s since the start, and copied the + # keystore only when it changed: on the start and once more for v2 + sleep 6 + test "$(copies keystore)" = 2 start=$SECONDS docker stop -t 60 test_secret test $((SECONDS - start)) -lt 30 @@ -1043,14 +1091,18 @@ jobs: docker start test_secret healthy grep -q "CN=secret-v2" <<< "$(served)" + keystore secret-v3 changeit2 + sleep 12 + docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker stop -t 60 test_secret docker rm test_secret - keystore secret-v3 docker run -d --memory="1g" -e SECRET_VOLUME_REFRESH=0 -p 127.0.0.1:1637:1636 --name=test_secret \ -v "$SECRETS":/var/secrets/opendj:ro -v test_secret_data:/opt/opendj/data "$IMAGE" healthy grep -q "CN=secret-v3" <<< "$(served)" - keystore secret-v4 + # no watcher, whose sleep is nearly always there, and nothing copied while the server runs + docker exec test_secret sh -c '! grep -sqx sleep /proc/[0-9]*/comm' + keystore secret-v4 changeit2 sleep 15 docker exec test_secret sh -c '! cmp -s /var/secrets/opendj/keystore /opt/opendj/data/config/keystore' docker rm -f test_secret diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index 0cd511fe28..ab2e3ce785 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -58,9 +58,11 @@ With the default `OPENDJ_SSL_OPTIONS` the instance serves LDAPS and StartTLS wit self-signed certificate from `config/keystore`, whose password is in `config/keystore.pin`. To serve your own certificate, mount a directory holding a `keystore` (JKS or PKCS12) and its `keystore.pin` at `SECRET_VOLUME`, and a `truststore` next to them if clients present -certificates. With the default options the connection handlers are not bound to an alias, -so the key entry of the keystore may have any alias; if `OPENDJ_SSL_OPTIONS` sets a -`--certNickname`, the key has to be under that alias: +certificates. With `--generateSelfSignedCertificate` setup binds the connection handlers to +no alias, even when a `--certNickname` is given as well, so the key entry of the keystore +may have any alias. Only when `OPENDJ_SSL_OPTIONS` sets up a keystore of its own +(`--useJavaKeystore`, `--usePkcs12keyStore`) with a `--certNickname` does the key have to be +under that alias: ```bash docker run -d --name opendj -v opendj-data:/opt/opendj/data \ @@ -72,9 +74,13 @@ before the server starts - on the first start and on every later one, so a certi renewed on the volume reaches an instance kept on a persistent volume. While the server runs, the directory is checked again every `SECRET_VOLUME_REFRESH` seconds and a changed file is copied again. The server reads the copied keystore when it starts, so a certificate -renewed while it runs is served from its next restart. The same holds for a new keystore -password: the server keeps the one it started with. The administration connector and -replication keep keys of their own and are not affected. +renewed while it runs is served from its next restart. A new password is left to the next +start altogether: the server keeps the one it started with, and a keystore it could no longer +open would disable the LDAPS handler on the next change to its configuration. So while a +`keystore.pin` or `truststore.pin` on the volume differs from the one the server started +with, nothing is copied until the next start, which copies the stores along with their new +password. The administration connector and replication keep keys of their own and are not +affected. On Kubernetes, the PEM files of a `kubernetes.io/tls` Secret cannot be used as they are: OpenDJ reads keystores, not PEM. cert-manager can add a PKCS12 keystore to the Secret it diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index 11d24a0089..5f4f2a066c 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -56,12 +56,14 @@ SECRET_VOLUME_REFRESH=${SECRET_VOLUME_REFRESH:-60} # Copies the key* and trust* files of the secret volume that differ from those in # ./data/config. Each one is written next to its target and renamed over it, so the server # never reads a file half copied, and it is readable by the server's user only: a keystore -# holds the private key, a .pin file its password. Succeeds when it copied a file. +# holds the private key, a .pin file its password. With "stores", the .pin files are left +# alone. Succeeds when it copied a file. copy_secrets() { local src dst tmp copied=1 [ -d "$SECRET_VOLUME" ] || return 1 for src in "$SECRET_VOLUME"/key* "$SECRET_VOLUME"/trust*; do [ -f "$src" ] || continue + [ "$1" = stores ] && [[ $src == *.pin ]] && continue dst=./data/config/$(basename -- "$src") cmp -s "$src" "$dst" && continue if tmp=$(mktemp "$dst.XXXXXX") && cp "$src" "$tmp" && chmod 600 "$tmp" && mv -f "$tmp" "$dst"; then @@ -80,12 +82,33 @@ copy_secrets() { # nothing left to copy, which puts the files of a single version back together. sync_secrets() { local passes=0 - while copy_secrets && [ $((passes += 1)) -lt 5 ]; do :; done + while copy_secrets "$@" && [ $((passes += 1)) -lt 5 ]; do :; done +} + +# The server reads a .pin file when it starts and keeps that password, but it opens the keystore +# file again whenever a connection handler checks a change to its configuration: a keystore it can +# no longer open with that password makes the next dsconfig change to the LDAPS handler disable +# the handler. So while the server runs, the .pin files in ./data/config stay those it started +# with, and the stores are not copied as long as a password on the volume differs from them; +# the next start copies the new files. +pins_unchanged() { + local src + for src in "$SECRET_VOLUME"/key*.pin "$SECRET_VOLUME"/trust*.pin; do + [ -f "$src" ] || continue + cmp -s "$src" "./data/config/$(basename -- "$src")" || return 1 + done } watch_secrets() { + local held=false while sleep "$SECRET_VOLUME_REFRESH"; do - sync_secrets + if pins_unchanged; then + held=false + sync_secrets stores + elif [ "$held" = false ]; then + held=true + echo "A password on the secret volume changed, the secret volume is copied again on the next start" + fi done } @@ -95,9 +118,9 @@ start_server() { if [ -d "$SECRET_VOLUME" ]; then echo "Secret volume is present. Will copy any keystores and truststore" sync_secrets - if [ "$SECRET_VOLUME_REFRESH" -gt 0 ] 2>/dev/null; then + if [[ $SECRET_VOLUME_REFRESH =~ ^[0-9]+$ ]] && [ "$SECRET_VOLUME_REFRESH" -gt 0 ]; then watch_secrets & - elif [ "$SECRET_VOLUME_REFRESH" != 0 ]; then + elif ! [[ $SECRET_VOLUME_REFRESH =~ ^0+$ ]]; then echo "SECRET_VOLUME_REFRESH=$SECRET_VOLUME_REFRESH is not a whole number of seconds above 0, the secret volume is copied on start only" fi fi @@ -167,6 +190,7 @@ fi # replication - is in place from here on, so the health check may start probing the server if [ "$BOOTSTRAPPED" = true ]; then touch "$BOOTSTRAP_COMPLETE" + echo "The instance is bootstrapped, the health check may probe it" fi start_server