Merge pull request #90 from theta42/feature/multi-master-ldap-location

feat: N-Way Multi-Master LDAP & User Location
This commit is contained in:
2026-07-21 00:14:22 -04:00
committed by GitHub
13 changed files with 246 additions and 0 deletions
+2
View File
@@ -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).
+1
View File
@@ -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 \
+1
View File
@@ -44,6 +44,7 @@ phone-home, no hosted control plane, and no per-user pricing.
drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
the pieces separately against your own LDAP/Redis via `app_*` env config.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
## Why this over the alternatives
+36
View File
@@ -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:
+29
View File
@@ -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
+1
View File
@@ -56,6 +56,7 @@ backend, that's the niche.
Emby, …) uses LDAPS/StartTLS against the same directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
## Get it
+55
View File
@@ -0,0 +1,55 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
## Why and when to use this?
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
### Example using `theta-env` / Docker Compose
**Site 1 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
## User Locations
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
+15
View File
@@ -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.
+39
View File
@@ -145,6 +145,45 @@ router.get('/token', function(req, res, next) {
res.render('token', {...values});
});
router.get('/sites', async function(req, res, next) {
const net = require('net');
const url = require('url');
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
const hosts = hostsStr.split(' ').filter(h => h);
const sites = await Promise.all(hosts.map(hostUrl => {
return new Promise((resolve) => {
try {
const u = new url.URL(hostUrl);
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
const hostname = u.hostname;
const socket = new net.Socket();
socket.setTimeout(2000);
socket.on('connect', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Online' });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
});
socket.on('error', (err) => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
});
socket.connect(port, hostname);
} catch (e) {
resolve({ url: hostUrl, status: 'Invalid URL' });
}
});
}));
res.render('sites', { ...values, myId, sites });
});
router.get('/login/resetpassword/:token', async function(req, res, next){
let token = await PasswordResetToken.get(req.params.token);
+5
View File
@@ -240,6 +240,7 @@
<i>Phone:</i> <b>{{mobile}}</b>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
<br />
<i>Location (Site):</i> <b>{{location}} </b><br />
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
@@ -323,6 +324,10 @@
<label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site)</label>
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
</div>
<div class="mb-3">
<label class="form-label">Home Directory</label>
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
+51
View File
@@ -0,0 +1,51 @@
<%- include('header') %>
<div class="container-fluid" style="margin-top: 80px;">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-lg mb-4">
<div class="card-header">
<i class="fa-solid fa-network-wired"></i> Sites & Replication
</div>
<div class="card-body">
<p class="mb-4">
This page shows the status of Multi-Master LDAP replication peers.
<br/>Your Server ID: <strong><%= myId %></strong>
</p>
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Site LDAP URL</th>
<th>Replication Status</th>
</tr>
</thead>
<tbody>
<% if (sites.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
</tr>
<% } else { %>
<% sites.forEach(function(site) { %>
<tr>
<td class="align-middle"><strong><%= site.url %></strong></td>
<td class="align-middle">
<% if (site.status === 'Online') { %>
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
<% } else { %>
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
<% } %>
</td>
</tr>
<% }); %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<%- include('footer') %>
+6
View File
@@ -56,6 +56,12 @@
Invites
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/sites">
<i class="fa-solid fa-network-wired"></i>
Sites
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/dashboard">
<i class="fa-solid fa-gauge-high"></i>
+5
View File
@@ -141,6 +141,11 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control shadow" name="location" placeholder="Site One" />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>