diff --git a/CHANGELOG.md b/CHANGELOG.md index c126ca0..ef7e61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Added +- Add optional `openldap-tls-reload` service to restart slapd after TLS file + contents change, ignoring duplicate events and unchanged projected volumes. - Add optional file-based auditing with `OPENLDAP_BOOTSTRAP_AUDITLOG_ENABLED` and `OPENLDAP_BOOTSTRAP_AUDITLOG_FILE`; load `auditlog.so` automatically when enabled. - Add `OPENLDAP_BOOTSTRAP_DATA_LAST_BIND` and `OPENLDAP_BOOTSTRAP_DATA_LAST_BIND_PRECISION` @@ -18,6 +20,8 @@ Project-specific changes only. See upstream [OpenLDAP release notes](https://www ### Fixed +- Create bootstrap, schema conversion and TLS watcher temporary files under + `/run/container/tmp` instead of relying on a writable `/tmp`. - Render bootstrap LDIF templates in a temporary writable copy, allowing read-only sources and Kubernetes projected-volume symlinks without modifying source files. diff --git a/Dockerfile b/Dockerfile index 07b5584..ac27bba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE="osixia/baseimage:alpine-2.0.0" +ARG BASE_IMAGE="osixia/baseimage:alpine-2.1.0" FROM ${BASE_IMAGE} ARG IMAGE="osixia/openldap:develop" diff --git a/README.md b/README.md index f1b38f3..af22389 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ docker run --name openldap --hostname ldap.example.org -p 389:3890 -p 636:6360 \ If you also set `OPENLDAP_BOOTSTRAP_TLS_REQUIRED=true`, bootstrap adds TLS-required access rules. That setting is stricter than plain TLS enablement and is intended for deployments where non-TLS client access should be rejected. +The optional `openldap-tls-reload` service restarts slapd when certificate contents change. See [certificate renewal](https://opensource.osixia.net/projects/container-images/openldap/tls/#renewing-certificates). + ## 🔁 Replication Set `OPENLDAP_BOOTSTRAP_REPLICATION=true` to bootstrap syncrepl replication. diff --git a/build/README.md b/build/README.md index 5f4d8ed..646cced 100644 --- a/build/README.md +++ b/build/README.md @@ -23,6 +23,9 @@ After the shared version check, `tests/` runs integration scenarios on each buil - default bootstrap; - read-only bootstrap templates with projected-volume symlinks, custom overrides and temporary-copy cleanup on success and failure; +- TLS certificate renewals: restart slapd on changed contents, ignore identical + projected volumes and duplicate events, and preserve intentional service stops; + run bootstrap, schema conversion and TLS renewals with `/tmp` mounted read-only; - alternative schemas (`nis`, `virtualMail`); - auditlog: automatic module loading, writes, custom paths, rotation and restarts; - last successful binds: disabled by default, precision throttling, failed binds diff --git a/build/main.go b/build/main.go index 75d022d..829227e 100644 --- a/build/main.go +++ b/build/main.go @@ -17,7 +17,7 @@ func main() { // image var OpenLDAPImage = &config.Image{ - BaseImage: "osixia/baseimage:alpine-2.0.0", + BaseImage: "osixia/baseimage:alpine-2.1.0", Distribution: config.Alpine, Name: "osixia/openldap", diff --git a/build/tests/test.go b/build/tests/test.go index 7824c53..e029004 100644 --- a/build/tests/test.go +++ b/build/tests/test.go @@ -21,6 +21,7 @@ var scenarios = []scenario{ {"default bootstrap", Default}, {"read-only bootstrap templates", ReadOnlyTemplates}, {"alternative schemas", AlternativeSchemas}, + {"TLS certificate reload", TLSReload}, {"audit logging", AuditLog}, {"last successful binds", LastBind}, {"refint empty-group placeholders", RefintNothing}, diff --git a/build/tests/testdata/tls-reload.sh b/build/tests/testdata/tls-reload.sh new file mode 100644 index 0000000..d00e734 --- /dev/null +++ b/build/tests/testdata/tls-reload.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# All runtime scratch files must work with /tmp mounted read-only. +if touch /tmp/openldap-write-probe 2>/dev/null; then + fail '/tmp is unexpectedly writable' +fi +cat > /run/container/runtime-temp.schema <<'SCHEMA' +attributetype ( 1.3.6.1.4.1.4203.666.11.999.1 NAME 'runtimeTempAttribute' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +SCHEMA +openldap-ctl schema2ldif /run/container/runtime-temp.schema > /run/container/schema-conversion.log +test -s /run/container/runtime-temp.ldif + +mkdir /run/container/projected +ln -s /run/container/certs1 /run/container/projected/..data +for name in tls.crt tls.key ca.crt; do + ln -s "..data/${name}" "/run/container/projected/${name}" +done + +container --kill-all=false -x openldap -x openldap-bootstrap -x openldap-upgrade \ + -x openldap-tls-reload > /run/container/tls-container.log 2>&1 & +runner=$! +trap 'kill -TERM "${runner}" 2>/dev/null || true; wait "${runner}" || true' EXIT + +serial() { + openssl s_client -connect 127.0.0.1:6360 /dev/null | openssl x509 -noout -serial +} +wait_serial() { + local expected="$1" + for _ in {1..150}; do + if [ "$(serial 2>/dev/null || true)" = "${expected}" ]; then + return 0 + fi + sleep 0.1 + done + cat /run/container/tls-container.log + fail "TLS certificate did not become ${expected}" +} +rotate() { + ln -s "$1" /run/container/projected/..next + mv -Tf /run/container/projected/..next /run/container/projected/..data +} +restart_count() { + grep -c 'TLS file contents changed; restarting OpenLDAP' /run/container/tls-container.log || true +} +assert_no_restart() { + sleep 3 + [ "$(cat /run/container/processes/openldap.pid)" = "${original_pid}" ] || fail 'unchanged TLS files restarted slapd' + [ "$(restart_count)" = "${expected_restarts}" ] || fail 'unexpected TLS restart' +} +expected1=$(openssl x509 -in /run/container/certs1/tls.crt -noout -serial) +expected2=$(openssl x509 -in /run/container/certs2/tls.crt -noout -serial) +wait_serial "${expected1}" +for _ in {1..100}; do + if grep -q 'Watching changes on' /run/container/tls-container.log; then + break + fi + sleep 0.1 +done +sleep 0.2 +original_pid=$(cat /run/container/processes/openldap.pid) +expected_restarts=0 + +# Metadata events and identical rewrites must not restart slapd. +touch /run/container/certs1/tls.crt +cp /run/container/certs1/tls.crt /run/container/copy.crt +cat /run/container/copy.crt > /run/container/certs1/tls.crt +assert_no_restart + +# Even a forced Kubernetes-style projection with identical bytes is ignored. +cp -R /run/container/certs1 /run/container/certs-identical +rotate /run/container/certs-identical +assert_no_restart + +# Multiple events from one renewal must cause exactly one restart. +rotate /run/container/certs2 +wait_serial "${expected2}" +[ "$(cat /run/container/processes/openldap.pid)" != "${original_pid}" ] || fail 'slapd was not restarted' +original_pid=$(cat /run/container/processes/openldap.pid) +expected_restarts=1 +assert_no_restart + +# A second renewal still works and duplicate callbacks remain harmless. +rotate /run/container/certs1 +wait_serial "${expected1}" +[ "$(cat /run/container/processes/openldap.pid)" != "${original_pid}" ] || fail 'second renewal did not restart slapd' +original_pid=$(cat /run/container/processes/openldap.pid) +expected_restarts=2 +assert_no_restart + +# Ordinary directory mounts: replacing symlinks with identical regular files +# must not restart slapd either, then a direct multi-file renewal restarts once. +for name in tls.crt tls.key ca.crt; do + cp -L "/run/container/projected/${name}" "/run/container/${name}" + mv -f "/run/container/${name}" "/run/container/projected/${name}" +done +assert_no_restart +for name in tls.crt tls.key ca.crt; do + cp "/run/container/certs2/${name}" "/run/container/projected/${name}" +done +wait_serial "${expected2}" +[ "$(cat /run/container/processes/openldap.pid)" != "${original_pid}" ] || fail 'direct renewal did not restart slapd' +original_pid=$(cat /run/container/processes/openldap.pid) +expected_restarts=3 +assert_no_restart + +# A watcher event must not undo an intentional administrative stop. +container processes stop openldap +for name in tls.crt tls.key ca.crt; do + cp "/run/container/certs1/${name}" "/run/container/projected/${name}" +done +sleep 3 +[ -e /run/container/processes/openldap.down ] || fail 'intentional stop was undone' +[ "$(restart_count)" = 3 ] || fail 'stopped slapd was restarted' +kill -0 "${runner}" +echo 'TLS reload checks: OK' diff --git a/build/tests/tls.go b/build/tests/tls.go new file mode 100644 index 0000000..9ccda2a --- /dev/null +++ b/build/tests/tls.go @@ -0,0 +1,37 @@ +package tests + +import ( + "context" + _ "embed" + "fmt" + + "dagger.io/dagger" +) + +// TLSReload verifies certificate rotation on new TLS connections by restarting only after file contents change. +func TLSReload(ctx context.Context, client *dagger.Client, image *dagger.Container) error { + passwords, err := rootPasswords(ctx, image) + if err != nil { + return err + } + c := withEnv(image, merge(passwords, env{ + "OPENLDAP_BOOTSTRAP_TLS": "true", + "OPENLDAP_BOOTSTRAP_TLS_CERT": "/run/container/projected/tls.crt", + "OPENLDAP_BOOTSTRAP_TLS_CERT_KEY": "/run/container/projected/tls.key", + "OPENLDAP_BOOTSTRAP_TLS_CA_CERT": "/run/container/projected/ca.crt", + })).WithUser("0"). + WithDirectory("/run/container/certs1", certificates(client, []string{"localhost"}), dagger.ContainerWithDirectoryOpts{Owner: ldapUser}). + WithDirectory("/run/container/certs2", certificates(client, []string{"localhost"}), dagger.ContainerWithDirectoryOpts{Owner: ldapUser}). + WithNewFile("/run/container/tls-reload-test.sh", tlsReloadCheck). + WithExec([]string{"container", "packages", "install", "openssl"}). + WithMountedDirectory("/tmp", client.Directory(), dagger.ContainerWithMountedDirectoryOpts{ReadOnly: true}). + WithUser(ldapUser) + _, err = expect(ctx, c, true, []string{"TLS reload checks: OK"}, "container", "run", "--", "bash", "/run/container/tls-reload-test.sh") + if err != nil { + return fmt.Errorf("TLS reload: %w", err) + } + return nil +} + +//go:embed testdata/tls-reload.sh +var tlsReloadCheck string diff --git a/services/openldap-bootstrap/startup.sh b/services/openldap-bootstrap/startup.sh index 52519f7..e18ddd6 100755 --- a/services/openldap-bootstrap/startup.sh +++ b/services/openldap-bootstrap/startup.sh @@ -16,7 +16,7 @@ fi container log info "Bootstrapping OpenLDAP ..." -BOOTSTRAP_TMP_DIR="$(mktemp -d)" +BOOTSTRAP_TMP_DIR="$(mktemp -d /run/container/tmp/openldap-bootstrap.XXXXXX)" trap 'rm -rf "${BOOTSTRAP_TMP_DIR}"' EXIT bootstrap_env() { diff --git a/services/openldap-tls-reload/.optional b/services/openldap-tls-reload/.optional new file mode 100644 index 0000000..e69de29 diff --git a/services/openldap-tls-reload/assets/reload.sh b/services/openldap-tls-reload/assets/reload.sh new file mode 100755 index 0000000..dd4cadb --- /dev/null +++ b/services/openldap-tls-reload/assets/reload.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +# Group file events from the same renewal, then compare contents, not timestamps +# or symlink targets. The baseimage watcher runs callbacks sequentially. +sleep 1 +if ! fingerprint=$(sha256sum -- "${OPENLDAP_BOOTSTRAP_TLS_CERT}" "${OPENLDAP_BOOTSTRAP_TLS_CERT_KEY}" \ + "${OPENLDAP_BOOTSTRAP_TLS_CA_CERT}" | sha256sum); then + container log error "Cannot read TLS files; OpenLDAP was not restarted" + exit 0 +fi + +if [ "${fingerprint}" = "$(cat "${OPENLDAP_TLS_RELOAD_STATE_DIR:?}/fingerprint")" ]; then + container log debug "TLS file contents unchanged; skipping restart" + exit 0 +fi + +# Preserve an intentional stop requested through the baseimage CLI. +if [ -e /run/container/processes/openldap.down ] || [ ! -e /run/container/processes/openldap.pid ]; then + container log warning "TLS files changed but OpenLDAP is not running; skipping restart" + exit 0 +fi + +container log info "TLS file contents changed; restarting OpenLDAP ..." +if container processes stop openldap && container processes start openldap; then + printf '%s\n' "${fingerprint}" > "${OPENLDAP_TLS_RELOAD_STATE_DIR}/fingerprint" +else + container log error "Failed to restart OpenLDAP after TLS files changed" +fi diff --git a/services/openldap-tls-reload/process.sh b/services/openldap-tls-reload/process.sh new file mode 100755 index 0000000..2d56867 --- /dev/null +++ b/services/openldap-tls-reload/process.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -euo pipefail + +# if container log level is trace: +# print commands and their arguments as they are executed +container log level eq trace && set -x + +OPENLDAP_TLS_RELOAD_STATE_DIR=$(mktemp -d /run/container/tmp/openldap-tls-reload.XXXXXX) +export OPENLDAP_TLS_RELOAD_STATE_DIR +trap 'rm -rf "${OPENLDAP_TLS_RELOAD_STATE_DIR:?}"' EXIT + +sha256sum -- "${OPENLDAP_BOOTSTRAP_TLS_CERT}" "${OPENLDAP_BOOTSTRAP_TLS_CERT_KEY}" \ + "${OPENLDAP_BOOTSTRAP_TLS_CA_CERT}" | sha256sum > "${OPENLDAP_TLS_RELOAD_STATE_DIR}/fingerprint" + +# Keep this shell to clean up its private state when the watcher exits. +container watch "${OPENLDAP_BOOTSTRAP_TLS_CERT}" "${OPENLDAP_BOOTSTRAP_TLS_CERT_KEY}" \ + "${OPENLDAP_BOOTSTRAP_TLS_CA_CERT}" --exec /container/services/openldap-tls-reload/assets/reload.sh diff --git a/services/openldap/assets/openldap-ctl/command/schema2ldif.sh b/services/openldap/assets/openldap-ctl/command/schema2ldif.sh index c1f07eb..4cf71e1 100755 --- a/services/openldap/assets/openldap-ctl/command/schema2ldif.sh +++ b/services/openldap/assets/openldap-ctl/command/schema2ldif.sh @@ -18,8 +18,8 @@ fi SCHEMAS=$1 DEPENDENCIES=${2:-${OPENLDAP_CTL_SCHEMA2LDIF_DEPENDENCIES}} -TMP_DIR=$(mktemp -d) -TMP_FILE=$(mktemp) +TMP_DIR=$(mktemp -d /run/container/tmp/openldap-schema.XXXXXX) +TMP_FILE=$(mktemp /run/container/tmp/openldap-schema-config.XXXXXX) cleanup() { rm -rf "${TMP_DIR}"