diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index ef58cd6..2777e00 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
| `PORT` | `3001` | host port mapped to the UI |
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
+| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
+| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
Any `app_*` var may also be set directly to override any config value (see the
table at the top).
diff --git a/Dockerfile.openldap b/Dockerfile.openldap
index 10e90ad..b8520b3 100644
--- a/Dockerfile.openldap
+++ b/Dockerfile.openldap
@@ -55,6 +55,7 @@ RUN apk add --no-cache \
openldap-overlay-ppolicy \
openldap-overlay-memberof \
openldap-overlay-refint \
+ openldap-overlay-syncprov \
openldap-passwd-sha2 \
dumb-init \
bash \
diff --git a/docker-compose.repl-test.yml b/docker-compose.repl-test.yml
new file mode 100644
index 0000000..1b8a833
--- /dev/null
+++ b/docker-compose.repl-test.yml
@@ -0,0 +1,36 @@
+services:
+ site1:
+ build: .
+ container_name: sso_site1
+ environment:
+ - LDAP_SERVER_ID=1
+ - LDAP_REPLICATION_HOSTS=ldap://site2:389
+ - LDAP_BASE_DN=dc=test,dc=local
+ - LDAP_ADMIN_PASS=secret
+ ports:
+ - "3001:3001"
+ - "10389:389"
+ volumes:
+ - site1-ldap:/var/lib/ldap
+ - site1-redis:/data
+
+ site2:
+ build: .
+ container_name: sso_site2
+ environment:
+ - LDAP_SERVER_ID=2
+ - LDAP_REPLICATION_HOSTS=ldap://site1:389
+ - LDAP_BASE_DN=dc=test,dc=local
+ - LDAP_ADMIN_PASS=secret
+ ports:
+ - "3002:3001"
+ - "20389:389"
+ volumes:
+ - site2-ldap:/var/lib/ldap
+ - site2-redis:/data
+
+volumes:
+ site1-ldap:
+ site1-redis:
+ site2-ldap:
+ site2-redis:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index c95d6af..c3aa61c 100755
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -125,6 +125,8 @@ include /etc/openldap/schema/theta42.schema
include /etc/openldap/schema/sudo.schema
include /etc/openldap/schema/openssh-lpk.schema
+SERVER_ID_PLACEHOLDER
+
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
@@ -137,6 +139,7 @@ moduleload pw-sha2
moduleload ppolicy
moduleload memberof
moduleload refint
+SYNCPROV_MODULE_PLACEHOLDER
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
# generated/mounted above. We accept clients without their own cert (the common
@@ -184,6 +187,8 @@ memberof-memberof-ad memberOf
overlay refint
refint_attributes memberOf member manager owner
+REPLICATION_BLOCK_PLACEHOLDER
+
# Access controls
access to attrs=userPassword
by dn="BIND_DN_PLACEHOLDER" write
@@ -211,6 +216,30 @@ else
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
fi
+# ── Multi-Master Replication Configuration ──
+if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
+ info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
+ sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
+ sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
+
+ # Generate syncrepl blocks
+ REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
+ RID=100
+ for HOST in ${LDAP_REPLICATION_HOSTS}; do
+ RID=$((RID + 1))
+ REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
+ done
+ REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
+
+ # Replace placeholder (awk is safer for multiline replacements than sed)
+ awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
+ mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
+else
+ sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
+ sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
+ sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
+fi
+
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js
index 30fe669..11fd5cf 100644
--- a/nodejs/models/user_ldap.js
+++ b/nodejs/models/user_ldap.js
@@ -146,6 +146,10 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob;
}
+ if (data.location) {
+ entry.l = data.location;
+ }
+
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
@@ -221,6 +225,7 @@ const user_parse = function(data){
data.username = data[conf.userNameAttribute]
data.userPassword = undefined;
}
+ data.location = data.l ? String(data.l) : '';
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
@@ -519,6 +524,16 @@ User.update = async function(data){
this.dateOfBirth = data.dateOfBirth;
}
+ if(data.location !== undefined){
+ await client.modify(this.dn, [
+ new Change({
+ operation: 'replace',
+ modification: new Attribute({ type: 'l', values: [data.location] }),
+ }),
+ ]);
+ this.location = data.location;
+ }
+
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
diff --git a/nodejs/views/profile.ejs b/nodejs/views/profile.ejs
index 08b33b4..dc97b4d 100644
--- a/nodejs/views/profile.ejs
+++ b/nodejs/views/profile.ejs
@@ -240,6 +240,7 @@
Phone: {{mobile}}
{{#phoneVerified}} Verified{{/phoneVerified}}
+ Location (Site): {{location}}
LDAP DN: {{dn}}
Home Directory: {{homeDirectory}}
Login Shell: {{loginShell}}
@@ -323,6 +324,10 @@
+