From 76fda3176cfb2d66a6619728511914270db6a306 Mon Sep 17 00:00:00 2001 From: Steffen Heil | secforge Date: Wed, 13 May 2026 16:33:45 +0200 Subject: [PATCH 3/3] tests: add Docker-based LDAP STARTTLS live integration test ldap_starttls_live_test.sh runs against a real OpenLDAP server (osixia/openldap) in Docker. It tests: Test A: ldapsearch (STARTTLS) -> stunnel server (protocol=ldap) -> slapd A.1: anonymous STARTTLS rootDSE query A.2: authenticated STARTTLS search (-ZZ) A.3: plain LDAP rejected (stunnel requires STARTTLS) Test B: full client+server proxy chain B.1: plain ldapsearch through client->server proxy chain B.2: ldapadd + ldapsearch through proxy chain Requires Docker, ldapsearch/ldapadd, and openssl. Not part of make check. Co-Authored-By: Claude Sonnet 4.6 --- tests/ldap_starttls_live_test.sh | 302 +++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100755 tests/ldap_starttls_live_test.sh diff --git a/tests/ldap_starttls_live_test.sh b/tests/ldap_starttls_live_test.sh new file mode 100755 index 0000000..76d3b6a --- /dev/null +++ b/tests/ldap_starttls_live_test.sh @@ -0,0 +1,302 @@ +#!/bin/bash +# Live integration test: LDAP StartTLS through stunnel server-side proxy. +# +# Topology: +# ldapsearch (STARTTLS) -> stunnel server (protocol=ldap) -> slapd (plain LDAP) +# +# Also tests the full proxy chain: +# ldapsearch (plain) -> stunnel client (protocol=ldap) -> stunnel server (protocol=ldap) -> slapd + +set -e +set -u +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +STUNNEL="$REPO_ROOT/src/stunnel" +CERTDIR="$SCRIPT_DIR/certs" +TMPDIR_BASE="$(mktemp -d /tmp/stunnel_ldap_live_XXXXXX)" +CONTAINER_NAME="stunnel-ldap-test-$$" + +LDAP_CONTAINER_PORT=10389 # slapd plain LDAP mapped to this host port +STUNNEL_SERVER_PORT=10636 # stunnel accepts STARTTLS here (server-side test) +STUNNEL_CLIENT_PORT=10388 # stunnel client accepts plain here (proxy chain test) +STUNNEL_PROXY_SERVER_PORT=10637 # stunnel server for the proxy chain test + +LDAP_BASE="dc=example,dc=org" +LDAP_ADMIN_DN="cn=admin,dc=example,dc=org" +LDAP_ADMIN_PASS="adminpassword" + +PASS=0 +FAIL=0 + +# Colour helpers +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +ok() { echo -e "${GREEN}PASS${NC}: $*"; PASS=$((PASS+1)); } +fail() { echo -e "${RED}FAIL${NC}: $*"; FAIL=$((FAIL+1)); } +info() { echo "INFO: $*"; } + +cleanup() { + info "Cleaning up..." + # Kill stunnel instances + for pidfile in "$TMPDIR_BASE"/*.pid; do + [ -f "$pidfile" ] && kill "$(cat "$pidfile")" 2>/dev/null || true + done + # Remove container + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + rm -rf "$TMPDIR_BASE" +} +trap cleanup EXIT + +# ── 1. Start slapd in Docker ────────────────────────────────────────────────── +info "Starting slapd container (plain LDAP on port $LDAP_CONTAINER_PORT)..." +docker run -d \ + --name "$CONTAINER_NAME" \ + -p "127.0.0.1:${LDAP_CONTAINER_PORT}:389" \ + -e LDAP_ORGANISATION="Example Org" \ + -e LDAP_DOMAIN="example.org" \ + -e LDAP_ADMIN_PASSWORD="$LDAP_ADMIN_PASS" \ + -e LDAP_TLS=false \ + osixia/openldap:1.5.0 \ + >/dev/null + +# Wait for slapd to be ready (typically ~8s) +info "Waiting for slapd to become ready..." +sleep 5 +for i in $(seq 1 30); do + if ldapsearch -x -H "ldap://127.0.0.1:${LDAP_CONTAINER_PORT}" \ + -b "$LDAP_BASE" -D "$LDAP_ADMIN_DN" -w "$LDAP_ADMIN_PASS" \ + "(objectClass=*)" dn >/dev/null 2>&1; then + info "slapd is ready (${i}s)" + break + fi + sleep 1 +done + +# Verify slapd is actually answering +if ! ldapsearch -x -H "ldap://127.0.0.1:${LDAP_CONTAINER_PORT}" \ + -b "$LDAP_BASE" -D "$LDAP_ADMIN_DN" -w "$LDAP_ADMIN_PASS" \ + "(objectClass=*)" dn >/dev/null 2>&1; then + echo "ERROR: slapd did not start in time" >&2 + exit 1 +fi + +# ── 2. Generate a self-signed cert trusted by ldapsearch ───────────────────── +info "Generating a CA + server cert for stunnel..." +CAKEY="$TMPDIR_BASE/ca.key" +CACERT="$TMPDIR_BASE/ca.crt" +SRVKEY="$TMPDIR_BASE/server.key" +SRVCSR="$TMPDIR_BASE/server.csr" +SRVCERT="$TMPDIR_BASE/server.crt" +SRVPEM="$TMPDIR_BASE/server.pem" + +openssl genrsa -out "$CAKEY" 2048 2>/dev/null +openssl req -new -x509 -days 3650 -key "$CAKEY" -out "$CACERT" \ + -subj "/CN=StunnelTestCA" 2>/dev/null +openssl genrsa -out "$SRVKEY" 2048 2>/dev/null +openssl req -new -key "$SRVKEY" -out "$SRVCSR" \ + -subj "/CN=127.0.0.1" 2>/dev/null +openssl x509 -req -days 3650 -in "$SRVCSR" -CA "$CACERT" -CAkey "$CAKEY" \ + -CAcreateserial -out "$SRVCERT" \ + -extfile <(printf "subjectAltName=IP:127.0.0.1") 2>/dev/null +cat "$SRVCERT" "$SRVKEY" > "$SRVPEM" + +# ── 3. Test A: server-side STARTTLS ────────────────────────────────────────── +# ldapsearch (STARTTLS) -> stunnel server (protocol=ldap) -> slapd (plain) +info "" +info "=== Test A: stunnel server-side LDAP STARTTLS ===" + +STUNNEL_A_CONF="$TMPDIR_BASE/stunnel_server.conf" +STUNNEL_A_PID="$TMPDIR_BASE/stunnel_server.pid" +STUNNEL_A_LOG="$TMPDIR_BASE/stunnel_server.log" + +cat > "$STUNNEL_A_CONF" </dev/null | grep -q ":${STUNNEL_SERVER_PORT}"; then + fail "Test A: stunnel server not listening on port $STUNNEL_SERVER_PORT" + cat "$STUNNEL_A_LOG" >&2 +else + info "stunnel server-side STARTTLS proxy is up on port $STUNNEL_SERVER_PORT" + + # Test A.1: anonymous bind (read rootDSE) with STARTTLS + info "A.1 Anonymous STARTTLS bind / rootDSE search..." + if LDAPTLS_REQCERT=never ldapsearch -x \ + -H "ldap://127.0.0.1:${STUNNEL_SERVER_PORT}" \ + -Z \ + -b "" \ + -s base \ + "(objectClass=*)" \ + namingContexts \ + 2>/dev/null | grep -q "namingContexts"; then + ok "Test A.1: anonymous STARTTLS rootDSE query succeeded" + else + fail "Test A.1: anonymous STARTTLS rootDSE query failed" + cat "$STUNNEL_A_LOG" >&2 + fi + + # Test A.2: authenticated search with STARTTLS (-Z forces STARTTLS, -ZZ requires it) + info "A.2 Authenticated STARTTLS bind / base search..." + if LDAPTLS_CACERT="$CACERT" ldapsearch -x \ + -H "ldap://127.0.0.1:${STUNNEL_SERVER_PORT}" \ + -ZZ \ + -b "$LDAP_BASE" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASS" \ + "(objectClass=*)" dn \ + 2>/dev/null | grep -q "dn:"; then + ok "Test A.2: authenticated STARTTLS search succeeded" + else + fail "Test A.2: authenticated STARTTLS search failed" + cat "$STUNNEL_A_LOG" >&2 + fi + + # Test A.3: plain (non-STARTTLS) connection must still work (stunnel speaks plain to client until STARTTLS) + info "A.3 Verify plain LDAP to stunnel port fails (STARTTLS is required by protocol)..." + # A plain LDAP bind to the stunnel port will fail because stunnel waits for + # the LDAP StartTLS ExtendedRequest before the TLS handshake. + if ldapsearch -x \ + -H "ldap://127.0.0.1:${STUNNEL_SERVER_PORT}" \ + -b "" \ + -s base \ + "(objectClass=*)" \ + namingContexts \ + >/dev/null 2>&1; then + fail "Test A.3: plain LDAP to stunnel server-side port unexpectedly succeeded" + else + ok "Test A.3: plain LDAP (no STARTTLS) to stunnel server-side port correctly rejected" + fi +fi + +# ── 4. Test B: full proxy chain (client+server stunnel, protocol=ldap) ─────── +# ldapsearch (plain) -> stunnel client (protocol=ldap) -> stunnel server (protocol=ldap) -> slapd +info "" +info "=== Test B: stunnel client+server LDAP STARTTLS proxy chain ===" + +STUNNEL_B_SRV_CONF="$TMPDIR_BASE/stunnel_proxy_server.conf" +STUNNEL_B_SRV_PID="$TMPDIR_BASE/stunnel_proxy_server.pid" +STUNNEL_B_SRV_LOG="$TMPDIR_BASE/stunnel_proxy_server.log" +STUNNEL_B_CLI_CONF="$TMPDIR_BASE/stunnel_proxy_client.conf" +STUNNEL_B_CLI_PID="$TMPDIR_BASE/stunnel_proxy_client.pid" +STUNNEL_B_CLI_LOG="$TMPDIR_BASE/stunnel_proxy_client.log" + +cat > "$STUNNEL_B_SRV_CONF" < "$STUNNEL_B_CLI_CONF" </dev/null | grep -q ":${STUNNEL_CLIENT_PORT}"; then + fail "Test B: stunnel client proxy not listening on port $STUNNEL_CLIENT_PORT" + cat "$STUNNEL_B_CLI_LOG" >&2 + cat "$STUNNEL_B_SRV_LOG" >&2 +else + info "stunnel client proxy is up on port $STUNNEL_CLIENT_PORT" + + # Test B.1: plain ldapsearch through the proxy chain (STARTTLS is transparent) + info "B.1 Plain ldapsearch through client->server proxy chain..." + if ldapsearch -x \ + -H "ldap://127.0.0.1:${STUNNEL_CLIENT_PORT}" \ + -b "$LDAP_BASE" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASS" \ + "(objectClass=*)" dn \ + 2>/dev/null | grep -q "dn:"; then + ok "Test B.1: plain LDAP through client→server proxy chain succeeded" + else + fail "Test B.1: plain LDAP through client→server proxy chain failed" + info "--- client log ---" + cat "$STUNNEL_B_CLI_LOG" >&2 + info "--- server log ---" + cat "$STUNNEL_B_SRV_LOG" >&2 + fi + + # Test B.2: add an entry and search for it through the proxy + info "B.2 ldapadd + ldapsearch through proxy chain..." + LDIF="$TMPDIR_BASE/test.ldif" + cat > "$LDIF" </dev/null 2>&1 || true # may already exist + + if ldapsearch -x \ + -H "ldap://127.0.0.1:${STUNNEL_CLIENT_PORT}" \ + -b "ou=people,dc=example,dc=org" \ + -D "$LDAP_ADMIN_DN" \ + -w "$LDAP_ADMIN_PASS" \ + "(uid=testuser)" cn mail \ + 2>/dev/null | grep -q "cn: Test User"; then + ok "Test B.2: ldapadd + ldapsearch through proxy chain succeeded" + else + fail "Test B.2: ldapadd + ldapsearch through proxy chain failed" + cat "$STUNNEL_B_CLI_LOG" >&2 + cat "$STUNNEL_B_SRV_LOG" >&2 + fi +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "────────────────────────────────────────" +echo "Results: ${PASS} passed, ${FAIL} failed" +echo "────────────────────────────────────────" +[ "$FAIL" -eq 0 ] || exit 1 -- 2.43.0