feat: restart openldap when tls file contents change

Add an optional TLS watcher with content fingerprints to ignore duplicate
notifications and unchanged Kubernetes projections.

Use explicit paths under /run/container/tmp for runtime temporary files
and require baseimage 2.1.0. Cover TLS renewal and read-only /tmp.
This commit is contained in:
Bertrand Gouny
2026-10-19 14:59:54 +02:00
parent bd8218b8a5
commit bb946e50eb
13 changed files with 230 additions and 5 deletions
+4
View File
@@ -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.
+1 -1
View File
@@ -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"
+2
View File
@@ -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.
+3
View File
@@ -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
+1 -1
View File
@@ -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",
+1
View File
@@ -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},
+126
View File
@@ -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 2>/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'
+37
View File
@@ -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
+1 -1
View File
@@ -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() {
+34
View File
@@ -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
+18
View File
@@ -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
@@ -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}"