diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8469aec276..4c3eaca697 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -523,17 +523,28 @@ jobs: shell: bash run: | trap 'code=$?; echo "::group::container logs (test)"; docker logs test 2>&1 || true; echo "::endgroup::"; exit $code' ERR - docker run --rm -it -d --memory="512m" --name=test localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} + docker run -it -d --memory="512m" --name=test localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test | grep -q \"healthy\"; do sleep 10; done' docker exec test 'sh' '-c' '/opt/opendj/bin/dsconfig create-backend --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --backend-name=example2 --type je --set=base-dn:dc=example2,dc=com --set=enabled:true --no-prompt --trustAll' docker exec test 'sh' '-c' '/opt/opendj/bin/makeldif -o /tmp/test.ldif -c suffix=dc=example2,dc=com /opt/opendj/data/config/MakeLDIF/example.template' - docker exec test 'sh' '-c' '/opt/opendj/bin/stop-ds' - docker exec test 'sh' '-c' '/opt/opendj/bin/import-ldif --offline --ldifFile /tmp/test.ldif --backendID=example2' - docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --offline --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll' - docker exec test 'sh' '-c' '/opt/opendj/bin/start-ds' - docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll --trustAll' + docker exec test 'sh' '-c' '/opt/opendj/bin/import-ldif --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --ldifFile /tmp/test.ldif --backendID=example2 --trustAll' + # the server is PID 1 of the container, so stopping it stops the container; the + # container that bootstrapped the instance has to stop the server on SIGTERM, not + # sit out the timeout and be killed. The server run in the foreground reports its + # shutdown only in the error log, which is read once the container is back up + stopped=$(docker exec test grep -c "The Directory Server is now stopped" /opt/opendj/data/logs/errors || true) + start=$SECONDS + docker stop -t 60 test + echo "stopped in $((SECONDS - start)) s, exit code $(docker inspect --format='{{.State.ExitCode}}' test)" + test $((SECONDS - start)) -lt 50 + test "$(docker inspect --format='{{.State.ExitCode}}' test)" -ne 137 + # a restart runs the server of the instance already there + docker start test + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test | grep -q \"healthy\"; do sleep 10; done' + test "$(docker exec test grep -c "The Directory Server is now stopped" /opt/opendj/data/logs/errors)" -gt "$stopped" + docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll --trustAll' docker exec test 'sh' '-c' '/opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "ou=people,dc=example2,dc=com" --searchScope sub "(uid=user.*)" dn | grep ^dn: | wc -l | grep -q 10000' - docker kill test + docker rm -f test - name: Docker test custom password shell: bash run: | @@ -603,6 +614,82 @@ jobs: if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done 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); + # 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() { + 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 "$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" + } + 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" + 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 + 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 + test "$(docker inspect --format='{{.State.ExitCode}}' test_secret)" = 143 + 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 + 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)" + # 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 + docker volume rm test_secret_data - name: Scan image for vulnerabilities (Trivy) # trivy resolves the image from the local Docker daemon, so only the runner's # linux/amd64 manifest is scanned; cache: false keeps the ~1GB trivy DBs from @@ -736,17 +823,28 @@ jobs: shell: bash run: | trap 'code=$?; echo "::group::container logs (test)"; docker logs test 2>&1 || true; echo "::endgroup::"; exit $code' ERR - docker run --rm -it -d --memory="1g" --name=test localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine + docker run -it -d --memory="1g" --name=test localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test | grep -q \"healthy\"; do sleep 10; done' docker exec test 'sh' '-c' '/opt/opendj/bin/dsconfig create-backend --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --backend-name=example2 --type je --set=base-dn:dc=example2,dc=com --set=enabled:true --no-prompt --trustAll' docker exec test 'sh' '-c' '/opt/opendj/bin/makeldif -o /tmp/test.ldif -c suffix=dc=example2,dc=com /opt/opendj/data/config/MakeLDIF/example.template' - docker exec test 'sh' '-c' '/opt/opendj/bin/stop-ds' - docker exec test 'sh' '-c' '/opt/opendj/bin/import-ldif --offline --ldifFile /tmp/test.ldif --backendID=example2' - docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --offline --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll' - docker exec test 'sh' '-c' '/opt/opendj/bin/start-ds' - docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll --trustAll' + docker exec test 'sh' '-c' '/opt/opendj/bin/import-ldif --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --ldifFile /tmp/test.ldif --backendID=example2 --trustAll' + # the server is PID 1 of the container, so stopping it stops the container; the + # container that bootstrapped the instance has to stop the server on SIGTERM, not + # sit out the timeout and be killed. The server run in the foreground reports its + # shutdown only in the error log, which is read once the container is back up + stopped=$(docker exec test grep -c "The Directory Server is now stopped" /opt/opendj/data/logs/errors || true) + start=$SECONDS + docker stop -t 60 test + echo "stopped in $((SECONDS - start)) s, exit code $(docker inspect --format='{{.State.ExitCode}}' test)" + test $((SECONDS - start)) -lt 50 + test "$(docker inspect --format='{{.State.ExitCode}}' test)" -ne 137 + # a restart runs the server of the instance already there + docker start test + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test | grep -q \"healthy\"; do sleep 10; done' + test "$(docker exec test grep -c "The Directory Server is now stopped" /opt/opendj/data/logs/errors)" -gt "$stopped" + docker exec test 'sh' '-c' '/opt/opendj/bin/rebuild-index --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --baseDN "dc=example2,dc=com" --rebuildAll --trustAll' docker exec test 'sh' '-c' '/opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "ou=people,dc=example2,dc=com" --searchScope sub "(uid=user.*)" dn | grep ^dn: | wc -l | grep -q 10000' - docker kill test + docker rm -f test - name: Docker test custom password shell: bash run: | @@ -816,6 +914,82 @@ jobs: if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done 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); + # 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() { + 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 "$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" + } + 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" + 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 + 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 + test "$(docker inspect --format='{{.State.ExitCode}}' test_secret)" = 143 + 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 + 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)" + # 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 + docker volume rm test_secret_data - name: Scan image for vulnerabilities (Trivy) # trivy resolves the image from the local Docker daemon, so only the runner's # linux/amd64 manifest is scanned; cache: false keeps the ~1GB trivy DBs from 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 0b85aa0471..4a766aaa03 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -36,6 +36,74 @@ 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. 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 \ + -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. 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 +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 | @@ -47,7 +115,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 | -| 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 d1ca2bdc81..e86ec406e7 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -38,6 +38,89 @@ rm -f "$BOOTSTRAP_COMPLETE" # Kubernetes that outlives the container: the pod keeps its /dev/shm across container restarts rm -f /dev/shm/opendj-replicate.* +# 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. 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 + 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 +} + +# 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 + 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 +} + +# 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" + sync_secrets + if [[ $SECRET_VOLUME_REFRESH =~ ^[0-9]+$ ]] && [ "$SECRET_VOLUME_REFRESH" -gt 0 ]; 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" + 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 && \ @@ -53,8 +136,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 @@ -81,29 +163,20 @@ if [ -n "${MASTER_SERVER}" ] && [ -n "${OPENDJ_REPLICATION_TYPE}" ]; then fi 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 +# Setup started the server in the background, and it cannot stay that way: the container's +# PID 1 would be this script, which the kernel delivers no SIGTERM to, and the server would +# keep the certificate it was set up with rather than the one on the secret volume. So it is +# stopped here and started again in the foreground, the way every later start runs it. 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; a server still up when it gives up would make start-ds below refuse to start. +./bin/stop-ds || { echo "The server the bootstrap started did not stop (stop-ds exited $?)"; exit 1; } # 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" + echo "The instance is bootstrapped, the health check may probe it" fi -# Opendj is probably already started in detach mode at the install -if (bin/status -n | grep Started); then - echo "OpenDJ is started" - - # Use tail instead of sleep to allow the container to be stopped with SIGTERM - tail -f /dev/null -fi - -echo "Starting OpenDJ" -exec ./bin/start-ds --nodetach +start_server