From 2cd9c90a8d759359ffa23cb3dca82487b5109aad Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 24 Sep 2026 17:32:00 +0300 Subject: [PATCH 1/4] [#1092] Probe the Docker container's health without binding as the root user The HEALTHCHECK bound as ROOT_USER_DN with ROOT_PASSWORD, which is only the initial root password: once an operator changed it every probe failed with 49 and the container stayed unhealthy for good, and every probe put the password on a command line. The probe now lives in healthcheck.sh: it tests the bootstrap marker, then reads the root DSE with 1.1 anonymously. An instance that rejects unauthenticated requests (53) is probed with the account named by HEALTHCHECK_BIND_DN, its password read from HEALTHCHECK_BIND_PASSWORD_FILE. Fixes #1092 --- .github/workflows/build.yml | 62 +++++++++++++++++++ opendj-packages/opendj-docker/Dockerfile | 14 +++-- .../opendj-docker/Dockerfile-alpine | 14 +++-- opendj-packages/opendj-docker/README.md | 19 +++++- opendj-packages/opendj-docker/healthcheck.sh | 43 +++++++++++++ opendj-packages/opendj-docker/pom.xml | 2 + 6 files changed, 141 insertions(+), 13 deletions(-) create mode 100755 opendj-packages/opendj-docker/healthcheck.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8469aec276..06e92f1e36 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -603,6 +603,37 @@ jobs: if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done cleanup + - name: Docker test health check + shell: bash + run: | + # the ERR trap below has to fire for a check failing inside stays_healthy too + set -o errtrace + trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR + IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} + # three failed probes 5 s apart turn a container unhealthy well within the wait, so a + # container still healthy after it, with no failing streak, passed every probe since + stays_healthy() { + sleep 45 + test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + } + # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy + docker run --rm -it -d --memory="512m" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health | grep -q \"healthy\"; do sleep 10; done' + docker exec test_health /opt/opendj/bin/ldappasswordmodify --hostname localhost --port 1636 --useSsl --trustAll --bindDN "cn=Directory Manager" --bindPassword initial_password --currentPassword initial_password --newPassword rotated_password + stays_healthy test_health + docker kill test_health + # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file + printf password > "$RUNNER_TEMP/healthcheck_password" + chmod 644 "$RUNNER_TEMP/healthcheck_password" + docker run --rm -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --set reject-unauthenticated-requests:true --no-prompt --trustAll + # the setting has taken: the anonymous probe would now be refused + rc=0 + docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? + test "$rc" = 53 + stays_healthy test_health_bind + docker kill test_health_bind - 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 @@ -816,6 +847,37 @@ jobs: if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done cleanup + - name: Docker test health check + shell: bash + run: | + # the ERR trap below has to fire for a check failing inside stays_healthy too + set -o errtrace + trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR + IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine + # three failed probes 5 s apart turn a container unhealthy well within the wait, so a + # container still healthy after it, with no failing streak, passed every probe since + stays_healthy() { + sleep 45 + test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + } + # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy + docker run --rm -it -d --memory="1g" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health | grep -q \"healthy\"; do sleep 10; done' + docker exec test_health /opt/opendj/bin/ldappasswordmodify --hostname localhost --port 1636 --useSsl --trustAll --bindDN "cn=Directory Manager" --bindPassword initial_password --currentPassword initial_password --newPassword rotated_password + stays_healthy test_health + docker kill test_health + # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file + printf password > "$RUNNER_TEMP/healthcheck_password" + chmod 644 "$RUNNER_TEMP/healthcheck_password" + docker run --rm -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --set reject-unauthenticated-requests:true --no-prompt --trustAll + # the setting has taken: the anonymous probe would now be refused + rc=0 + docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? + test "$rc" = 53 + stays_healthy test_health_bind + docker kill test_health_bind - 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..16b141f517 100644 --- a/opendj-packages/opendj-docker/Dockerfile +++ b/opendj-packages/opendj-docker/Dockerfile @@ -65,8 +65,9 @@ RUN printf 'Acquire::ForceIPv4 "true";\nAcquire::Retries "5";\n' > /etc/apt/apt # root. The scripts copied below are only read and run, so they just keep the same group. COPY --chown=$OPENDJ_USER:0 bootstrap/ /opt/opendj/bootstrap/ COPY --chown=$OPENDJ_USER:0 run.sh /opt/opendj/run.sh +COPY --chown=$OPENDJ_USER:0 healthcheck.sh /opt/opendj/healthcheck.sh -RUN chmod +x /opt/opendj/run.sh /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh +RUN chmod +x /opt/opendj/run.sh /opt/opendj/healthcheck.sh /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh EXPOSE $PORT/tcp $LDAPS_PORT/tcp $ADMIN_PORT/tcp @@ -75,10 +76,11 @@ USER $OPENDJ_USER # "healthy" has to mean the instance is ready to serve, not just that it answers: setup # starts the server in the middle of the bootstrap, before the backend of BASE_DN is # created and its entries imported, so probing the root DSE alone reports ready while a -# search of BASE_DN still fails with "No Such Entry". Testing the marker first also keeps -# the probe from launching a JVM every interval until the bootstrap is through. The start -# period is what a bootstrap importing SAMPLE_DATA into a small container can take; a -# probe that succeeds ends it early, and a bootstrap that failed never writes the marker. -HEALTHCHECK --interval=30s --timeout=30s --start-period=5m --retries=3 CMD test -f "$BOOTSTRAP_COMPLETE" && opendj/bin/ldapsearch --hostname localhost --port $LDAPS_PORT --bindDN "$ROOT_USER_DN" --bindPassword "${ROOT_PASSWORD:-password}" --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || exit 1 +# search of BASE_DN still fails with "No Such Entry". healthcheck.sh tests the marker +# first, then searches the root DSE without binding as the root user, whose password the +# operator is expected to change. The start period is what a bootstrap importing +# SAMPLE_DATA into a small container can take; a probe that succeeds ends it early, and a +# bootstrap that failed never writes the marker. +HEALTHCHECK --interval=30s --timeout=30s --start-period=5m --retries=3 CMD ["/opt/opendj/healthcheck.sh"] ENTRYPOINT ["/opt/opendj/run.sh"] diff --git a/opendj-packages/opendj-docker/Dockerfile-alpine b/opendj-packages/opendj-docker/Dockerfile-alpine index bb47092286..b4b8a03468 100644 --- a/opendj-packages/opendj-docker/Dockerfile-alpine +++ b/opendj-packages/opendj-docker/Dockerfile-alpine @@ -69,8 +69,9 @@ RUN apk add --update --no-cache --virtual builddeps curl unzip \ # root. The scripts copied below are only read and run, so they just keep the same group. COPY --chown=$OPENDJ_USER:0 bootstrap/ /opt/opendj/bootstrap/ COPY --chown=$OPENDJ_USER:0 run.sh /opt/opendj/run.sh +COPY --chown=$OPENDJ_USER:0 healthcheck.sh /opt/opendj/healthcheck.sh -RUN chmod +x /opt/opendj/run.sh /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh +RUN chmod +x /opt/opendj/run.sh /opt/opendj/healthcheck.sh /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh EXPOSE $PORT/tcp $LDAPS_PORT/tcp $ADMIN_PORT/tcp @@ -79,10 +80,11 @@ USER $OPENDJ_USER # "healthy" has to mean the instance is ready to serve, not just that it answers: setup # starts the server in the middle of the bootstrap, before the backend of BASE_DN is # created and its entries imported, so probing the root DSE alone reports ready while a -# search of BASE_DN still fails with "No Such Entry". Testing the marker first also keeps -# the probe from launching a JVM every interval until the bootstrap is through. The start -# period is what a bootstrap importing SAMPLE_DATA into a small container can take; a -# probe that succeeds ends it early, and a bootstrap that failed never writes the marker. -HEALTHCHECK --interval=30s --timeout=30s --start-period=5m --retries=3 CMD test -f "$BOOTSTRAP_COMPLETE" && opendj/bin/ldapsearch --hostname localhost --port $LDAPS_PORT --bindDN "$ROOT_USER_DN" --bindPassword "${ROOT_PASSWORD:-password}" --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || exit 1 +# search of BASE_DN still fails with "No Such Entry". healthcheck.sh tests the marker +# first, then searches the root DSE without binding as the root user, whose password the +# operator is expected to change. The start period is what a bootstrap importing +# SAMPLE_DATA into a small container can take; a probe that succeeds ends it early, and a +# bootstrap that failed never writes the marker. +HEALTHCHECK --interval=30s --timeout=30s --start-period=5m --retries=3 CMD ["/opt/opendj/healthcheck.sh"] ENTRYPOINT ["/opt/opendj/run.sh"] diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index 0b85aa0471..c01e7cd04c 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -30,6 +30,21 @@ without `ADD_BASE_ENTRY` nothing creates the base entry, so `BASE_DN` is an empt a healthy container - the health check itself searches the root DSE, which every instance serves whatever it was set up to hold. +The health check does not bind as the root user: `ROOT_PASSWORD` is only the initial root +password, and a probe binding with it would turn the container `unhealthy` once that password +is changed. It reads the root DSE anonymously instead. An instance that rejects +unauthenticated requests (`reject-unauthenticated-requests:true`) answers that search with +`53 (Unwilling to Perform)`; for such an instance set `HEALTHCHECK_BIND_DN` to an account the +probe may bind as and `HEALTHCHECK_BIND_PASSWORD_FILE` to a file in the container holding its +password - the probe reads it from there, so it never shows on a command line: + +```bash +docker run -d --name opendj -v /path/to/secrets:/var/secrets/healthcheck:ro \ + -e HEALTHCHECK_BIND_DN="uid=monitor,ou=people,dc=example,dc=com" \ + -e HEALTHCHECK_BIND_PASSWORD_FILE=/var/secrets/healthcheck/password \ + openidentityplatform/opendj +``` + A bootstrap that imports `SAMPLE_DATA` can take minutes on a small container, which is what the start period allows for. A bootstrap that fails - or an upgrade that fails when starting over an instance that is already there - never reports healthy: what failed is in `docker @@ -56,4 +71,6 @@ turning `unhealthy` once the start period is over. | OPENDJ_JAVA_ARGS | -server | extra instance java args | | BACKEND_TYPE | je | OpenDJ backend type, see [dsconfig create-backend](https://doc.openidentityplatform.org/opendj/reference/dsconfig-subcommands-ref#dsconfig-create-backend) documentation | | BACKEND_DB_DIRECTORY | db | OpenDJ `db-directory` attribute for backend | -| SETUP_ARGS | - | extra setup args | \ No newline at end of file +| SETUP_ARGS | - | extra setup args | +| HEALTHCHECK_BIND_DN | - | DN the health check binds as, for an instance that rejects unauthenticated requests; unset, the health check searches the root DSE anonymously | +| HEALTHCHECK_BIND_PASSWORD_FILE | - | file in the container holding the password of `HEALTHCHECK_BIND_DN` | \ No newline at end of file diff --git a/opendj-packages/opendj-docker/healthcheck.sh b/opendj-packages/opendj-docker/healthcheck.sh new file mode 100755 index 0000000000..3623bff7d0 --- /dev/null +++ b/opendj-packages/opendj-docker/healthcheck.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# The container health check +# +# The marker comes first: run.sh writes it only once the bootstrap has succeeded, and testing +# it also keeps the probe from launching a JVM every interval until then. +# +# The probe then reads the root DSE with the attribute list 1.1, which needs no bind. It must +# not bind as the root user: ROOT_PASSWORD is only the initial root password, so the probe +# would turn the container unhealthy for good once an operator changes it, and it would put +# the password on a command line every interval. An instance configured to reject +# unauthenticated requests answers the anonymous search with 53 (Unwilling to Perform); for +# such an instance HEALTHCHECK_BIND_DN names an account to bind with, and its password is +# read from HEALTHCHECK_BIND_PASSWORD_FILE, never passed on a command line. +# +# Docker reserves exit code 2, so whatever failed is reported as 1. + +test -f "${BOOTSTRAP_COMPLETE:-/opt/opendj/.bootstrap-complete}" || exit 1 + +BIND_ARGS=() +if [ -n "${HEALTHCHECK_BIND_DN}" ]; then + if [ ! -r "${HEALTHCHECK_BIND_PASSWORD_FILE}" ]; then + echo "HEALTHCHECK_BIND_DN is set, but HEALTHCHECK_BIND_PASSWORD_FILE '${HEALTHCHECK_BIND_PASSWORD_FILE}' is not a readable file" + exit 1 + fi + BIND_ARGS=(--bindDN "${HEALTHCHECK_BIND_DN}" --bindPasswordFile "${HEALTHCHECK_BIND_PASSWORD_FILE}") +fi + +/opt/opendj/bin/ldapsearch --hostname localhost --port "${LDAPS_PORT:-1636}" --useSsl --trustAll \ + "${BIND_ARGS[@]}" --baseDN "" --searchScope base "(objectClass=*)" 1.1 || exit 1 diff --git a/opendj-packages/opendj-docker/pom.xml b/opendj-packages/opendj-docker/pom.xml index 55b839e7c8..ff7ddb9014 100644 --- a/opendj-packages/opendj-docker/pom.xml +++ b/opendj-packages/opendj-docker/pom.xml @@ -13,6 +13,7 @@ information: "Portions Copyright [year] [name of copyright owner]". Copyright 2018-2019 Open Identity Platform Community. + Portions Copyright 2026 3A Systems, LLC. --> 4.0.0 @@ -81,6 +82,7 @@ + From 22baf00c437e2ea4fef92f5b3149244bb218feaa Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 12:14:02 +0300 Subject: [PATCH 2/4] [#1092] Keep a tools.properties from making the health check bind, and test that the probe fails when it should - healthcheck.sh passes --noPropertiesFile: the toolkit ldapsearch otherwise reads ~/.opendj/tools.properties, and /home/opendj exists in the image, so bind settings put there for the CLI would turn the anonymous probe into a bind whose password can change. - README: an instance that rejects unauthenticated requests was healthy on images that probed as root; it has to set HEALTHCHECK_BIND_DN and HEALTHCHECK_BIND_PASSWORD_FILE before it starts on this one. - CI, both docker jobs: - stays_healthy reads the status every 2 s for 45 s instead of once at the end, so a single failed probe in the window fails the step; - the probe itself is run with HEALTHCHECK_BIND_DN cleared and must exit 1 on the instance that rejects unauthenticated requests; - with an unreadable password file it must exit 1 and say so; - the bind case uses a root password of its own, and the command lines of every process in the container are sampled for two probe intervals to check it never appears on one. --- .github/workflows/build.yml | 58 +++++++++++++++----- opendj-packages/opendj-docker/README.md | 5 ++ opendj-packages/opendj-docker/healthcheck.sh | 5 +- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 06e92f1e36..6200b14e2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -610,11 +610,14 @@ jobs: set -o errtrace trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} - # three failed probes 5 s apart turn a container unhealthy well within the wait, so a - # container still healthy after it, with no failing streak, passed every probe since + # a failed probe leaves a failing streak until the next probe passes, 5 s later at the + # earliest, so a container found "healthy 0" every 2 s for 45 s passed every probe since stays_healthy() { - sleep 45 - test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + local end=$((SECONDS + 45)) + while [ $SECONDS -lt $end ]; do + test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + sleep 2 + done } # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy docker run --rm -it -d --memory="512m" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE @@ -623,16 +626,28 @@ jobs: stays_healthy test_health docker kill test_health # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file - printf password > "$RUNNER_TEMP/healthcheck_password" + # a password of its own, so the command lines below can be searched for it + printf hc_secret_1092 > "$RUNNER_TEMP/healthcheck_password" chmod 644 "$RUNNER_TEMP/healthcheck_password" - docker run --rm -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + docker run --rm -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' - docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --set reject-unauthenticated-requests:true --no-prompt --trustAll + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword hc_secret_1092 --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused rc=0 docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? test "$rc" = 53 + # and the probe reports that refusal when it has no account to bind with + rc=0 + docker exec -e HEALTHCHECK_BIND_DN= test_health_bind /opt/opendj/healthcheck.sh || rc=$? + test "$rc" = 1 + # a password file it cannot read is reported as such + rc=0 + out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=/nonexistent test_health_bind /opt/opendj/healthcheck.sh) || rc=$? + test "$rc" = 1 + grep -q 'is not a readable file' <<< "$out" stays_healthy test_health_bind + # the password never shows on a command line: sample every process's for two probe intervals + docker exec test_health_bind sh -c 'end=$(($(date +%s) + 12)); while [ "$(date +%s)" -lt "$end" ]; do for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f" 2>/dev/null; echo; done | grep -q "[h]c_secret_1092" && exit 1; sleep 0.2; done; exit 0' docker kill test_health_bind - name: Scan image for vulnerabilities (Trivy) # trivy resolves the image from the local Docker daemon, so only the runner's @@ -854,11 +869,14 @@ jobs: set -o errtrace trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine - # three failed probes 5 s apart turn a container unhealthy well within the wait, so a - # container still healthy after it, with no failing streak, passed every probe since + # a failed probe leaves a failing streak until the next probe passes, 5 s later at the + # earliest, so a container found "healthy 0" every 2 s for 45 s passed every probe since stays_healthy() { - sleep 45 - test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + local end=$((SECONDS + 45)) + while [ $SECONDS -lt $end ]; do + test "$(docker inspect --format='{{.State.Health.Status}} {{.State.Health.FailingStreak}}' "$1")" = "healthy 0" + sleep 2 + done } # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy docker run --rm -it -d --memory="1g" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE @@ -867,16 +885,28 @@ jobs: stays_healthy test_health docker kill test_health # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file - printf password > "$RUNNER_TEMP/healthcheck_password" + # a password of its own, so the command lines below can be searched for it + printf hc_secret_1092 > "$RUNNER_TEMP/healthcheck_password" chmod 644 "$RUNNER_TEMP/healthcheck_password" - docker run --rm -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + docker run --rm -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' - docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword password --set reject-unauthenticated-requests:true --no-prompt --trustAll + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword hc_secret_1092 --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused rc=0 docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? test "$rc" = 53 + # and the probe reports that refusal when it has no account to bind with + rc=0 + docker exec -e HEALTHCHECK_BIND_DN= test_health_bind /opt/opendj/healthcheck.sh || rc=$? + test "$rc" = 1 + # a password file it cannot read is reported as such + rc=0 + out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=/nonexistent test_health_bind /opt/opendj/healthcheck.sh) || rc=$? + test "$rc" = 1 + grep -q 'is not a readable file' <<< "$out" stays_healthy test_health_bind + # the password never shows on a command line: sample every process's for two probe intervals + docker exec test_health_bind sh -c 'end=$(($(date +%s) + 12)); while [ "$(date +%s)" -lt "$end" ]; do for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f" 2>/dev/null; echo; done | grep -q "[h]c_secret_1092" && exit 1; sleep 0.2; done; exit 0' docker kill test_health_bind - name: Scan image for vulnerabilities (Trivy) # trivy resolves the image from the local Docker daemon, so only the runner's diff --git a/opendj-packages/opendj-docker/README.md b/opendj-packages/opendj-docker/README.md index c01e7cd04c..c13a139b08 100644 --- a/opendj-packages/opendj-docker/README.md +++ b/opendj-packages/opendj-docker/README.md @@ -45,6 +45,11 @@ docker run -d --name opendj -v /path/to/secrets:/var/secrets/healthcheck:ro \ openidentityplatform/opendj ``` +Images before this one probed as the root user, so an existing instance that rejects +unauthenticated requests was healthy with them. Started on this image without these two +variables, the same instance is probed anonymously and turns `unhealthy` although it serves: +set them before the upgrade. + A bootstrap that imports `SAMPLE_DATA` can take minutes on a small container, which is what the start period allows for. A bootstrap that fails - or an upgrade that fails when starting over an instance that is already there - never reports healthy: what failed is in `docker diff --git a/opendj-packages/opendj-docker/healthcheck.sh b/opendj-packages/opendj-docker/healthcheck.sh index 3623bff7d0..a623a31042 100755 --- a/opendj-packages/opendj-docker/healthcheck.sh +++ b/opendj-packages/opendj-docker/healthcheck.sh @@ -24,7 +24,8 @@ # the password on a command line every interval. An instance configured to reject # unauthenticated requests answers the anonymous search with 53 (Unwilling to Perform); for # such an instance HEALTHCHECK_BIND_DN names an account to bind with, and its password is -# read from HEALTHCHECK_BIND_PASSWORD_FILE, never passed on a command line. +# read from HEALTHCHECK_BIND_PASSWORD_FILE, never passed on a command line. --noPropertiesFile +# keeps a tools.properties in the user's home from turning the probe into a bind of its own. # # Docker reserves exit code 2, so whatever failed is reported as 1. @@ -39,5 +40,5 @@ if [ -n "${HEALTHCHECK_BIND_DN}" ]; then BIND_ARGS=(--bindDN "${HEALTHCHECK_BIND_DN}" --bindPasswordFile "${HEALTHCHECK_BIND_PASSWORD_FILE}") fi -/opt/opendj/bin/ldapsearch --hostname localhost --port "${LDAPS_PORT:-1636}" --useSsl --trustAll \ +/opt/opendj/bin/ldapsearch --noPropertiesFile --hostname localhost --port "${LDAPS_PORT:-1636}" --useSsl --trustAll \ "${BIND_ARGS[@]}" --baseDN "" --searchScope base "(objectClass=*)" 1.1 || exit 1 From b272e533878112faeb42ede296ccfc5328508565 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 15:34:07 +0300 Subject: [PATCH 3/4] [#1092] Keep the root password off the command line of the Docker bootstrap as well - setup.sh writes ROOT_PASSWORD to a file readable by its owner only, on /dev/shm where there is one, and removes it on exit, as replicate.sh does since #1094. setup, dsconfig, import-ldif and ldapmodify read the password from it (--rootUserPasswordFile, --bindPasswordFile), so it no longer shows on a command line during the bootstrap, where the ps of the Docker host lists it to every user of the host. ldapmodify used to get it unquoted, which also split a password with a space in it. - run.sh removes a password file a killed setup.sh leaves in /dev/shm, next to the one of replicate.sh. - CI: - the replication step's check that no tool gets the password on its command line covers setup.sh too, --rootUserPassword included; - the check that no password is left in /tmp or /dev/shm covers the master, which only setup.sh bootstrapped, and no longer skips /tmp/hsperfdata_*: the HEALTHCHECK kept the root password there, and after this change no process left running has it; - the health check step's dsconfig reads the password from the mounted file, the one the probe reads, rather than taking it on its command line. --- .github/workflows/build.yml | 34 ++++++++++--------- .../opendj-docker/bootstrap/setup.sh | 23 +++++++++---- opendj-packages/opendj-docker/run.sh | 7 ++-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6200b14e2d..c7006fa6e2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -564,10 +564,10 @@ jobs: cleanup() { docker rm -f test_master $REPLICAS >/dev/null 2>&1 || true; docker network rm test_replication >/dev/null 2>&1 || true; } cleanup trap 'code=$?; for c in test_master $REPLICAS; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; echo "::endgroup::"; done; cleanup; exit $code' ERR - # every tool reads the root password from a file (#1084); dsreplication run with -n prints + # every tool reads the root password from a file (#1084, #1092); dsreplication run with -n prints # no command line, so a password put back on one would pass every check below - rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/replicate.sh || rc=$? - if [ $rc -ne 1 ]; then echo "::error::replicate.sh passes the root password on a command line, or grep could not read it"; false; fi + rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword|rootUserPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh || rc=$? + if [ $rc -ne 1 ]; then echo "::error::setup.sh or replicate.sh passes the root password on a command line, or grep could not read them"; false; fi # the password file goes to /dev/shm, off the writable layer of the container, and the mktemp of the image puts it there docker run --rm --entrypoint grep "$IMAGE" -qF -- 'mktemp -p /dev/shm opendj-replicate.' /opt/opendj/bootstrap/replicate.sh || { echo "::error::replicate.sh no longer puts the password file on /dev/shm"; false; } docker run --rm --entrypoint sh "$IMAGE" -c 'f=$(mktemp -p /dev/shm opendj-replicate.XXXXXX) && rm -f "$f" && case $f in /dev/shm/opendj-replicate.*) ;; *) exit 1;; esac' || { echo "::error::mktemp in the image does not create the password file on /dev/shm"; false; } @@ -593,13 +593,14 @@ jobs: for c in $REPLICAS; do timeout 1m bash -c 'until docker exec $1 /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$0" --useSsl --trustAll --baseDN "ou=replicated,dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1; do sleep 5; done' "$ROOT_PASSWORD" $c done - # the root password shows in no container log, and the file replicate.sh passed it in is gone (#1084) + # the root password shows in no container log, and the files setup.sh and replicate.sh passed it in are gone (#1084, #1092) for c in test_master $REPLICAS; do if docker logs $c 2>&1 | grep -F "$ROOT_PASSWORD"; then echo "::error::The root password is in the log of $c"; false; fi done - for c in $REPLICAS; do - # the JVM of the HEALTHCHECK's ldapsearch keeps its command line, root password included, in /tmp/hsperfdata_* while it runs - left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm | grep -v '^/tmp/hsperfdata_' || true) + for c in test_master $REPLICAS; do + # a JVM keeps its command line in /tmp/hsperfdata_* while it runs; the HEALTHCHECK no longer binds as root (#1092), + # so no process left running has the root password on it + left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm || true) if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done cleanup @@ -631,7 +632,7 @@ jobs: chmod 644 "$RUNNER_TEMP/healthcheck_password" docker run --rm -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' - docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword hc_secret_1092 --set reject-unauthenticated-requests:true --no-prompt --trustAll + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPasswordFile /tmp/healthcheck_password --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused rc=0 docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? @@ -823,10 +824,10 @@ jobs: cleanup() { docker rm -f test_master $REPLICAS >/dev/null 2>&1 || true; docker network rm test_replication >/dev/null 2>&1 || true; } cleanup trap 'code=$?; for c in test_master $REPLICAS; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; echo "::endgroup::"; done; cleanup; exit $code' ERR - # every tool reads the root password from a file (#1084); dsreplication run with -n prints + # every tool reads the root password from a file (#1084, #1092); dsreplication run with -n prints # no command line, so a password put back on one would pass every check below - rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/replicate.sh || rc=$? - if [ $rc -ne 1 ]; then echo "::error::replicate.sh passes the root password on a command line, or grep could not read it"; false; fi + rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword|rootUserPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/setup.sh /opt/opendj/bootstrap/replicate.sh || rc=$? + if [ $rc -ne 1 ]; then echo "::error::setup.sh or replicate.sh passes the root password on a command line, or grep could not read them"; false; fi # the password file goes to /dev/shm, off the writable layer of the container, and the mktemp of the image puts it there docker run --rm --entrypoint grep "$IMAGE" -qF -- 'mktemp -p /dev/shm opendj-replicate.' /opt/opendj/bootstrap/replicate.sh || { echo "::error::replicate.sh no longer puts the password file on /dev/shm"; false; } docker run --rm --entrypoint sh "$IMAGE" -c 'f=$(mktemp -p /dev/shm opendj-replicate.XXXXXX) && rm -f "$f" && case $f in /dev/shm/opendj-replicate.*) ;; *) exit 1;; esac' || { echo "::error::mktemp in the image does not create the password file on /dev/shm"; false; } @@ -852,13 +853,14 @@ jobs: for c in $REPLICAS; do timeout 1m bash -c 'until docker exec $1 /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$0" --useSsl --trustAll --baseDN "ou=replicated,dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1; do sleep 5; done' "$ROOT_PASSWORD" $c done - # the root password shows in no container log, and the file replicate.sh passed it in is gone (#1084) + # the root password shows in no container log, and the files setup.sh and replicate.sh passed it in are gone (#1084, #1092) for c in test_master $REPLICAS; do if docker logs $c 2>&1 | grep -F "$ROOT_PASSWORD"; then echo "::error::The root password is in the log of $c"; false; fi done - for c in $REPLICAS; do - # the JVM of the HEALTHCHECK's ldapsearch keeps its command line, root password included, in /tmp/hsperfdata_* while it runs - left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm | grep -v '^/tmp/hsperfdata_' || true) + for c in test_master $REPLICAS; do + # a JVM keeps its command line in /tmp/hsperfdata_* while it runs; the HEALTHCHECK no longer binds as root (#1092), + # so no process left running has the root password on it + left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm || true) if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi done cleanup @@ -890,7 +892,7 @@ jobs: chmod 644 "$RUNNER_TEMP/healthcheck_password" docker run --rm -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' - docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPassword hc_secret_1092 --set reject-unauthenticated-requests:true --no-prompt --trustAll + docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPasswordFile /tmp/healthcheck_password --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused rc=0 docker exec test_health_bind /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --useSsl --trustAll --baseDN "" --searchScope base "(objectClass=*)" 1.1 || rc=$? diff --git a/opendj-packages/opendj-docker/bootstrap/setup.sh b/opendj-packages/opendj-docker/bootstrap/setup.sh index c571af0af4..b5483dd15a 100755 --- a/opendj-packages/opendj-docker/bootstrap/setup.sh +++ b/opendj-packages/opendj-docker/bootstrap/setup.sh @@ -22,6 +22,15 @@ echo "Setting up default OpenDJ instance" +# The tools read the root password from a file, so that it shows on no command line while they +# run, where the ps of the Docker host lists it to every user of the host. As in replicate.sh, +# mktemp creates the file readable by its owner only, on the tmpfs of /dev/shm where there is +# one, and run.sh removes a file a killed setup.sh leaves there. run.sh runs this script with +# sh, so it keeps to POSIX sh. +PASSWORD_FILE=$(mktemp -p /dev/shm opendj-setup.XXXXXX 2>/dev/null || mktemp) || exit 1 +trap 'rm -f "$PASSWORD_FILE"' EXIT +printf '%s\n' "$ROOT_PASSWORD" >"$PASSWORD_FILE" || exit 1 + # If any optional LDIF files are present load them # There are multiple types of ldif files. @@ -45,7 +54,7 @@ fi --enableStartTLS $OPENDJ_SSL_OPTIONS \ --adminConnectorPort $ADMIN_PORT \ --rootUserDN "$ROOT_USER_DN" \ - --rootUserPassword "$ROOT_PASSWORD" \ + --rootUserPasswordFile "$PASSWORD_FILE" \ --acceptLicense \ --no-prompt \ --noPropertiesFile \ @@ -55,7 +64,7 @@ BACKEND_TYPE=${BACKEND_TYPE:-je} BACKEND_DB_DIRECTORY=${BACKEND_DB_DIRECTORY:-db} echo "creating backend: $BACKEND_TYPE db-directory: ${BACKEND_DB_DIRECTORY}" -/opt/opendj/bin/dsconfig create-backend -h localhost -p $ADMIN_PORT --bindDN "$ROOT_USER_DN" --bindPassword "$ROOT_PASSWORD" \ +/opt/opendj/bin/dsconfig create-backend -h localhost -p $ADMIN_PORT --bindDN "$ROOT_USER_DN" --bindPasswordFile "$PASSWORD_FILE" \ --backend-name=userRoot --type $BACKEND_TYPE --set base-dn:$BASE_DN --set "db-directory:$BACKEND_DB_DIRECTORY" \ --set enabled:true --no-prompt --trustAll || exit 1 @@ -65,13 +74,13 @@ if [ "$ADD_BASE_ENTRY" = "--addBaseEntry" ]; then echo "generating sample data..." /opt/opendj/bin/makeldif -o $BASE_TEMPLATE -c suffix="$BASE_DN" -c numusers=$SAMPLE_DATA /opt/opendj/template/config/MakeLDIF/example.template || exit 1 /opt/opendj/bin/import-ldif --ldifFile $BASE_TEMPLATE \ - --backendID=userRoot --bindDN "$ROOT_USER_DN" --bindPassword "$ROOT_PASSWORD" || exit 1 + --backendID=userRoot --bindDN "$ROOT_USER_DN" --bindPasswordFile "$PASSWORD_FILE" || exit 1 else echo "creating base entry..." BASE_TEMPLATE=$(mktemp) echo "branch: $BASE_DN" > $BASE_TEMPLATE /opt/opendj/bin/import-ldif --templateFile $BASE_TEMPLATE \ - --backendID=userRoot --bindDN "$ROOT_USER_DN" --bindPassword "$ROOT_PASSWORD" || exit 1 + --backendID=userRoot --bindDN "$ROOT_USER_DN" --bindPasswordFile "$PASSWORD_FILE" || exit 1 fi rm $BASE_TEMPLATE fi @@ -85,7 +94,7 @@ if [ -d /opt/opendj/bootstrap/schema/ ]; then echo "Loading initial schema:" for file in /opt/opendj/bootstrap/schema/*; do echo "Loading $file ..." - /opt/opendj/bin/ldapmodify -D "$ROOT_USER_DN" -h localhost -p $PORT -w $ROOT_PASSWORD -f $file + /opt/opendj/bin/ldapmodify -D "$ROOT_USER_DN" -h localhost -p $PORT --bindPasswordFile "$PASSWORD_FILE" -f $file done fi @@ -94,7 +103,7 @@ if [ -d /opt/opendj/bootstrap/data/ ]; then /opt/opendj/bin/dsconfig \ set-password-policy-prop \ --bindDN "$ROOT_USER_DN" \ - --bindPassword "$ROOT_PASSWORD" \ + --bindPasswordFile "$PASSWORD_FILE" \ --policy-name "Default Password Policy" \ --set allow-pre-encoded-passwords:true \ --trustAll \ @@ -102,6 +111,6 @@ if [ -d /opt/opendj/bootstrap/data/ ]; then for file in /opt/opendj/bootstrap/data/*; do echo "Loading $file ..." - /opt/opendj/bin/ldapmodify -D "$ROOT_USER_DN" -h localhost -p $PORT -w $ROOT_PASSWORD -f $file --continueOnError + /opt/opendj/bin/ldapmodify -D "$ROOT_USER_DN" -h localhost -p $PORT --bindPasswordFile "$PASSWORD_FILE" -f $file --continueOnError done fi diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh index d1ca2bdc81..ee35cf9f51 100755 --- a/opendj-packages/opendj-docker/run.sh +++ b/opendj-packages/opendj-docker/run.sh @@ -34,9 +34,10 @@ cd /opt/opendj BOOTSTRAP_COMPLETE=${BOOTSTRAP_COMPLETE:-/opt/opendj/.bootstrap-complete} rm -f "$BOOTSTRAP_COMPLETE" -# A replicate.sh killed before its EXIT trap ran leaves the root password in /dev/shm, and on -# Kubernetes that outlives the container: the pod keeps its /dev/shm across container restarts -rm -f /dev/shm/opendj-replicate.* +# A setup.sh or replicate.sh killed before its EXIT trap ran leaves the root password in +# /dev/shm, and on Kubernetes that outlives the container: the pod keeps its /dev/shm across +# container restarts +rm -f /dev/shm/opendj-setup.* /dev/shm/opendj-replicate.* #if default data folder exists do not change it if [ ! -d ./db ]; then From 4621a5c615120873ddade0d97a8f65b958fab4bd Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 25 Sep 2026 17:26:37 +0300 Subject: [PATCH 4/4] [#1092] Pin --noPropertiesFile and an unreadable password file in CI, and keep a failed container for the trap Both docker jobs, Docker test health check: - the rotation case writes bind settings with a wrong password to /home/opendj/.opendj/tools.properties before it checks the container stays healthy, so a probe without --noPropertiesFile fails there; - the unreadable password file case also runs with a file that exists with mode 000, which the probe, running as the image user rather than root, cannot read, so a guard weakened from -r to -e or -f fails it; - the two containers run without --rm and are removed by the ERR trap and at the end of the step, so one whose bootstrap failed is still there for the trap to print its log. --- .github/workflows/build.yml | 54 +++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7006fa6e2..a5eef7d092 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -609,7 +609,8 @@ jobs: run: | # the ERR trap below has to fire for a check failing inside stays_healthy too set -o errtrace - trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR + # the containers are run without --rm, so that one whose bootstrap failed is still there for the trap to print + trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; docker rm -f test_health test_health_bind >/dev/null 2>&1 || true; exit $code' ERR IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} # a failed probe leaves a failing streak until the next probe passes, 5 s later at the # earliest, so a container found "healthy 0" every 2 s for 45 s passed every probe since @@ -621,16 +622,18 @@ jobs: done } # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy - docker run --rm -it -d --memory="512m" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE + docker run -it -d --memory="512m" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health | grep -q \"healthy\"; do sleep 10; done' docker exec test_health /opt/opendj/bin/ldappasswordmodify --hostname localhost --port 1636 --useSsl --trustAll --bindDN "cn=Directory Manager" --bindPassword initial_password --currentPassword initial_password --newPassword rotated_password + # bind settings kept for the CLI in the home of the image user must not reach the probe + docker exec test_health sh -c 'mkdir -p /home/opendj/.opendj && printf "bindDN=cn=Directory Manager\nbindPassword=wrong_password\n" > /home/opendj/.opendj/tools.properties' stays_healthy test_health - docker kill test_health + docker rm -f test_health # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file # a password of its own, so the command lines below can be searched for it printf hc_secret_1092 > "$RUNNER_TEMP/healthcheck_password" chmod 644 "$RUNNER_TEMP/healthcheck_password" - docker run --rm -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + docker run -it -d --memory="512m" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPasswordFile /tmp/healthcheck_password --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused @@ -641,15 +644,19 @@ jobs: rc=0 docker exec -e HEALTHCHECK_BIND_DN= test_health_bind /opt/opendj/healthcheck.sh || rc=$? test "$rc" = 1 - # a password file it cannot read is reported as such - rc=0 - out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=/nonexistent test_health_bind /opt/opendj/healthcheck.sh) || rc=$? - test "$rc" = 1 - grep -q 'is not a readable file' <<< "$out" + # a password file it cannot read is reported as such, whether it is missing or there but not + # readable: the image runs as its own user, not root, so mode 000 keeps the probe out + docker exec test_health_bind sh -c 'touch /tmp/unreadable_password && chmod 000 /tmp/unreadable_password' + for f in /nonexistent /tmp/unreadable_password; do + rc=0 + out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=$f test_health_bind /opt/opendj/healthcheck.sh) || rc=$? + test "$rc" = 1 + grep -q 'is not a readable file' <<< "$out" + done stays_healthy test_health_bind # the password never shows on a command line: sample every process's for two probe intervals docker exec test_health_bind sh -c 'end=$(($(date +%s) + 12)); while [ "$(date +%s)" -lt "$end" ]; do for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f" 2>/dev/null; echo; done | grep -q "[h]c_secret_1092" && exit 1; sleep 0.2; done; exit 0' - docker kill test_health_bind + docker rm -f test_health_bind - 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 @@ -869,7 +876,8 @@ jobs: run: | # the ERR trap below has to fire for a check failing inside stays_healthy too set -o errtrace - trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; exit $code' ERR + # the containers are run without --rm, so that one whose bootstrap failed is still there for the trap to print + trap 'code=$?; for c in test_health test_health_bind; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; docker inspect --format="{{json .State.Health}}" $c 2>&1 || true; echo "::endgroup::"; done; docker rm -f test_health test_health_bind >/dev/null 2>&1 || true; exit $code' ERR IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine # a failed probe leaves a failing streak until the next probe passes, 5 s later at the # earliest, so a container found "healthy 0" every 2 s for 45 s passed every probe since @@ -881,16 +889,18 @@ jobs: done } # ROOT_PASSWORD is only the initial root password: changing it must not turn the container unhealthy - docker run --rm -it -d --memory="1g" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE + docker run -it -d --memory="1g" --health-interval=5s -e ROOT_PASSWORD=initial_password --name=test_health $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health | grep -q \"healthy\"; do sleep 10; done' docker exec test_health /opt/opendj/bin/ldappasswordmodify --hostname localhost --port 1636 --useSsl --trustAll --bindDN "cn=Directory Manager" --bindPassword initial_password --currentPassword initial_password --newPassword rotated_password + # bind settings kept for the CLI in the home of the image user must not reach the probe + docker exec test_health sh -c 'mkdir -p /home/opendj/.opendj && printf "bindDN=cn=Directory Manager\nbindPassword=wrong_password\n" > /home/opendj/.opendj/tools.properties' stays_healthy test_health - docker kill test_health + docker rm -f test_health # an instance rejecting unauthenticated requests is probed with the account it is given, whose password is read from a file # a password of its own, so the command lines below can be searched for it printf hc_secret_1092 > "$RUNNER_TEMP/healthcheck_password" chmod 644 "$RUNNER_TEMP/healthcheck_password" - docker run --rm -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE + docker run -it -d --memory="1g" --health-interval=5s -v "$RUNNER_TEMP/healthcheck_password:/tmp/healthcheck_password:ro" -e ROOT_PASSWORD=hc_secret_1092 -e HEALTHCHECK_BIND_DN="cn=Directory Manager" -e HEALTHCHECK_BIND_PASSWORD_FILE=/tmp/healthcheck_password --name=test_health_bind $IMAGE timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_health_bind | grep -q \"healthy\"; do sleep 10; done' docker exec test_health_bind /opt/opendj/bin/dsconfig set-global-configuration-prop --hostname localhost --port 4444 --bindDN "cn=Directory Manager" --bindPasswordFile /tmp/healthcheck_password --set reject-unauthenticated-requests:true --no-prompt --trustAll # the setting has taken: the anonymous probe would now be refused @@ -901,15 +911,19 @@ jobs: rc=0 docker exec -e HEALTHCHECK_BIND_DN= test_health_bind /opt/opendj/healthcheck.sh || rc=$? test "$rc" = 1 - # a password file it cannot read is reported as such - rc=0 - out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=/nonexistent test_health_bind /opt/opendj/healthcheck.sh) || rc=$? - test "$rc" = 1 - grep -q 'is not a readable file' <<< "$out" + # a password file it cannot read is reported as such, whether it is missing or there but not + # readable: the image runs as its own user, not root, so mode 000 keeps the probe out + docker exec test_health_bind sh -c 'touch /tmp/unreadable_password && chmod 000 /tmp/unreadable_password' + for f in /nonexistent /tmp/unreadable_password; do + rc=0 + out=$(docker exec -e HEALTHCHECK_BIND_PASSWORD_FILE=$f test_health_bind /opt/opendj/healthcheck.sh) || rc=$? + test "$rc" = 1 + grep -q 'is not a readable file' <<< "$out" + done stays_healthy test_health_bind # the password never shows on a command line: sample every process's for two probe intervals docker exec test_health_bind sh -c 'end=$(($(date +%s) + 12)); while [ "$(date +%s)" -lt "$end" ]; do for f in /proc/[0-9]*/cmdline; do tr "\0" " " < "$f" 2>/dev/null; echo; done | grep -q "[h]c_secret_1092" && exit 1; sleep 0.2; done; exit 0' - docker kill test_health_bind + docker rm -f test_health_bind - 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