From ff93f3bf42f4e2ed6e3cd90bb7983e4754e25bae Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Mon, 22 Jun 2026 10:55:39 +0000
Subject: [PATCH] Refine LDAP benchmark: READD, SSHA-256 hashing, grouped charts
---
.github/benchmark/summary.sh | 55 +++++++++++++++++----------
.github/benchmark/benchmark.jmx | 6 +-
.github/workflows/benchmark.yml | 57 +++++++++++++++++++++++-----
3 files changed, 84 insertions(+), 34 deletions(-)
diff --git a/.github/benchmark/benchmark.jmx b/.github/benchmark/benchmark.jmx
index 745cffc..78f3d04 100644
--- a/.github/benchmark/benchmark.jmx
+++ b/.github/benchmark/benchmark.jmx
@@ -17,7 +17,7 @@
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="OpenDJ vs OpenLDAP - LDAP benchmark" enabled="true">
- <stringProp name="TestPlan.comments">Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/ADD WITHOUT DELETE) reuse the cached admin connection. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.</stringProp>
+ <stringProp name="TestPlan.comments">Parametrized LDAP benchmark. Admin bind is cached once per thread (Once Only Controller, labelled ADMIN_CONNECT, excluded from metrics). Data ops (ADD/SEARCH/COMPARE/MODIFY/DELETE/READD) reuse the cached admin connection. The measured user authentication is a single bind/unbind (test=sbind, own connection) after MODIFY has set the userPassword.</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
@@ -268,8 +268,8 @@
</LDAPExtSampler>
<hashTree/>
- <!-- ADD WITHOUT DELETE (admin connection): accumulation; unique RDN via __UUID. -->
- <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="ADD WITHOUT DELETE" enabled="true">
+ <!-- READD (admin connection): re-add without delete (accumulation); unique RDN via __UUID. -->
+ <LDAPExtSampler guiclass="LdapExtTestSamplerGui" testclass="LDAPExtSampler" testname="READD" enabled="true">
<stringProp name="servername"></stringProp>
<stringProp name="port"></stringProp>
<stringProp name="rootdn"></stringProp>
diff --git a/.github/benchmark/summary.sh b/.github/benchmark/summary.sh
index ecac86c..193fb33 100644
--- a/.github/benchmark/summary.sh
+++ b/.github/benchmark/summary.sh
@@ -32,7 +32,7 @@
DJ_IMG="${6:-}"
# Operations to compare, in workflow order. ADMIN_CONNECT and Total are excluded.
-OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "ADD WITHOUT DELETE")
+OPS=("ADD" "SEARCH" "COMPARE" "MODIFY" "BIND" "DELETE" "READD")
# m <file> <label> <field> -> numeric value (0 if absent), rounded to 1 decimal.
m() { jq -r --arg l "$2" --arg f "$3" '((.[$l][$f]) // 0) | (.*10 | round / 10)' "$1"; }
@@ -85,51 +85,63 @@
echo ""
# ---------------------------------------------------------------- Chart helpers
-# Build a Mermaid list "x, y, z" of values for all OPS from <file> <field>, via <m|mi>.
-series() { # <fn> <file> <field>
- local fn="$1" file="$2" field="$3" out="" v
- for op in "${OPS[@]}"; do
- v=$("$fn" "$file" "$op" "$field")
- out+="${out:+, }${v}"
- done
+# Mermaid xychart-beta has no grouped bars/legend, so to get two adjacent (non-overlapping)
+# columns per operation we repeat each op on the x-axis and zero-pad the two series: OpenLDAP
+# bars land on the left tick of each pair, OpenDJ on the right tick.
+#
+# x-axis: each op twice -> "ADD", "ADD", "SEARCH", "SEARCH", ...
+xaxis_pairs() {
+ local out="" op
+ for op in "${OPS[@]}"; do out+="${out:+, }\"${op}\", \"${op}\""; done
printf '%s' "$out"
}
-# x-axis with every label quoted (labels contain spaces).
-xaxis() {
- local out="" op
- for op in "${OPS[@]}"; do out+="${out:+, }\"${op}\""; done
+# OpenLDAP series: value then 0 per op (bar on the left tick of each pair).
+series_ol() { # <fn> <file> <field>
+ local fn="$1" file="$2" field="$3" out="" v
+ for op in "${OPS[@]}"; do v=$("$fn" "$file" "$op" "$field"); out+="${out:+, }${v}, 0"; done
+ printf '%s' "$out"
+}
+# OpenDJ series: 0 then value per op (bar on the right tick of each pair).
+series_dj() { # <fn> <file> <field>
+ local fn="$1" file="$2" field="$3" out="" v
+ for op in "${OPS[@]}"; do v=$("$fn" "$file" "$op" "$field"); out+="${out:+, }0, ${v}"; done
printf '%s' "$out"
}
-XAXIS="$(xaxis)"
+XAXIS="$(xaxis_pairs)"
+# Fix the two series colors (series 1 = OpenLDAP blue, series 2 = OpenDJ orange).
+PALETTE='%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#4e79a7, #f28e2b"}}}}%%'
+CAPTION="_Each operation has two columns: ๐ฆ OpenLDAP (left) ยท ๐ง OpenDJ (right)._"
# ---------------------------------------------------------------- Throughput chart
echo "### Comparative chart โ throughput per operation (ops/s, higher is better)"
echo ""
-echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo "$CAPTION"
echo ""
echo '```mermaid'
+echo "$PALETTE"
echo "xychart-beta"
echo " title \"Throughput per operation (ops/s) โ OpenLDAP vs OpenDJ\""
echo " x-axis [${XAXIS}]"
echo " y-axis \"ops/s\""
-echo " bar [$(series m "$OL_JSON" throughput)]"
-echo " bar [$(series m "$DJ_JSON" throughput)]"
+echo " bar [$(series_ol m "$OL_JSON" throughput)]"
+echo " bar [$(series_dj m "$DJ_JSON" throughput)]"
echo '```'
echo ""
# ---------------------------------------------------------------- Latency chart
echo "### Comparative chart โ mean latency per operation (ms, lower is better)"
echo ""
-echo "_First bar series = OpenLDAP, second bar series = OpenDJ._"
+echo "$CAPTION"
echo ""
echo '```mermaid'
+echo "$PALETTE"
echo "xychart-beta"
echo " title \"Mean latency per operation (ms) โ OpenLDAP vs OpenDJ\""
echo " x-axis [${XAXIS}]"
echo " y-axis \"ms\""
-echo " bar [$(series m "$OL_JSON" meanResTime)]"
-echo " bar [$(series m "$DJ_JSON" meanResTime)]"
+echo " bar [$(series_ol m "$OL_JSON" meanResTime)]"
+echo " bar [$(series_dj m "$DJ_JSON" meanResTime)]"
echo '```'
echo ""
@@ -139,6 +151,7 @@
echo "- \`BIND\` is the measured **user authentication** (\`test=sbind\`, single bind/unbind on its"
echo " own connection) as \`cn=user_<n>,ou=People\` with the password set by \`MODIFY\`. The admin"
echo " connection bind (\`ADMIN_CONNECT\`) is cached once per thread and excluded from these results."
-echo "- Default password storage scheme differs by server (OpenDJ PBKDF2/Salted-SHA vs OpenLDAP"
-echo " SSHA); this is a legitimate part of authentication cost."
+echo "- MODIFY sends the password in cleartext; each server hashes it on write with the **same"
+echo " scheme (SSHA-256)** โ OpenLDAP via the pw-sha2 module + ppolicy hash-cleartext, OpenDJ via"
+echo " its Salted SHA-256 default scheme โ so BIND authentication is compared on equal footing."
echo "- Full interactive JMeter HTML dashboards are attached as the \`jmeter-reports\` artifact."
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index c8b7838..5dff071 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -78,7 +78,7 @@
- uses: actions/checkout@v6
- name: Cache JMeter
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/jmeter
key: jmeter-${{ env.JMETER }}
@@ -103,15 +103,32 @@
-e LDAP_TLS=false \
"$OPENLDAP_IMAGE"
- - name: Wait for OpenLDAP + seed ou=People
+ - name: Configure OpenLDAP (SSHA-256 hash-on-write, seed)
run: |
- for i in $(seq 1 90); do
- if ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
- -b "$BASEDN" -s base dn >/dev/null 2>&1; then
- echo "OpenLDAP is up"; break
- fi
- sleep 2
- done
+ wait_ldap() {
+ for i in $(seq 1 90); do
+ ldapsearch -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
+ -b "$BASEDN" -s base dn >/dev/null 2>&1 && { echo "OpenLDAP is up"; return 0; }
+ sleep 2
+ done
+ }
+ le() { docker exec -i openldap "$@" -Y EXTERNAL -H ldapi:/// >/dev/null 2>&1 || true; }
+ wait_ldap
+ # Load pw-sha2 (provides {SSHA256}) and ppolicy (overlay) modules; osixia ships both
+ # in /usr/lib/ldap. Create the module list entry or append the values, then restart so
+ # the modules are active in the running slapd.
+ printf 'dn: cn=module{0},cn=config\nobjectClass: olcModuleList\nolcModuleLoad: pw-sha2\nolcModuleLoad: ppolicy\n' | le ldapadd
+ printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: pw-sha2\n' | le ldapmodify
+ printf 'dn: cn=module{0},cn=config\nchangetype: modify\nadd: olcModuleLoad\nolcModuleLoad: ppolicy\n' | le ldapmodify
+ docker restart openldap
+ wait_ldap
+ # Make slapd hash cleartext userPassword with {SSHA256} on a plain modify: set the global
+ # password-hash and enable the ppolicy overlay's hash_cleartext on the mdb database.
+ printf 'dn: olcDatabase={-1}frontend,cn=config\nchangetype: modify\nreplace: olcPasswordHash\nolcPasswordHash: {SSHA256}\n' | le ldapmodify
+ DBDN="$(docker exec openldap ldapsearch -Y EXTERNAL -H ldapi:/// -LLL -b cn=config '(olcDatabase=*mdb)' dn 2>/dev/null | sed -n 's/^dn: //p' | head -1)"
+ [ -n "$DBDN" ] || DBDN="olcDatabase={1}mdb,cn=config"
+ printf 'dn: olcOverlay=ppolicy,%s\nobjectClass: olcOverlayConfig\nobjectClass: olcPPolicyConfig\nolcOverlay: ppolicy\nolcPPolicyHashCleartext: TRUE\n' "$DBDN" | le ldapadd
+ # Seed ou=People (after the restart so it survives any re-init).
ldapadd -x -H ldap://localhost:2389 -D "cn=admin,$BASEDN" -w password \
-f .github/benchmark/people.ldif || true
@@ -141,6 +158,11 @@
-Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
-l openldap.jtl -e -o openldap
+ - name: Stop OpenLDAP
+ run: |
+ docker logs openldap 2>&1 | tail -n 100 || true
+ docker rm -f openldap || true
+
- name: Start OpenDJ
run: |
docker run -d --name opendj -p 1389:1389 \
@@ -161,6 +183,16 @@
ldapadd -x -H ldap://localhost:1389 -D "cn=Directory Manager" -w password \
-f .github/benchmark/people.ldif || true
+ - name: Configure OpenDJ password policy (SSHA-256 hash-on-write)
+ run: |
+ # Hash cleartext userPassword with Salted SHA-256 on write, matching OpenLDAP.
+ docker exec opendj /opt/opendj/bin/dsconfig set-password-policy-prop \
+ --policy-name "Default Password Policy" \
+ --set default-password-storage-scheme:"Salted SHA-256" \
+ --hostname localhost --port 4444 \
+ --bindDN "cn=Directory Manager" --bindPassword password \
+ --trustAll --no-prompt
+
- name: Capture OpenDJ version
run: |
# `|| true` guards the bind so a transient ldapsearch failure can't trip pipefail;
@@ -187,6 +219,11 @@
-Jjmeter.reportgenerator.sample_filter="$SAMPLE_FILTER" \
-l opendj.jtl -e -o opendj
+ - name: Stop OpenDJ
+ run: |
+ docker logs opendj 2>&1 | tail -n 100 || true
+ docker rm -f opendj || true
+
# ---------------------------------------------------------------- Report
- name: Build job summary
run: |
@@ -197,7 +234,7 @@
- name: Upload JMeter reports
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: jmeter-reports
path: |
--
Gitblit v1.10.0