diff --git a/docs/index.md b/docs/index.md index 79989c8..123c4d7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/docs/replication.md b/docs/replication.md new file mode 100644 index 0000000..6a6d28d --- /dev/null +++ b/docs/replication.md @@ -0,0 +1,50 @@ +--- +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. + +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. diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 46742f0..eea6695 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -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); diff --git a/nodejs/views/sites.ejs b/nodejs/views/sites.ejs new file mode 100644 index 0000000..794f176 --- /dev/null +++ b/nodejs/views/sites.ejs @@ -0,0 +1,51 @@ +<%- include('header') %> + +
+
+
+
+
+ Sites & Replication +
+
+

+ This page shows the status of Multi-Master LDAP replication peers. +
Your Server ID: <%= myId %> +

+ + + + + + + + + + <% if (sites.length === 0) { %> + + + + <% } else { %> + <% sites.forEach(function(site) { %> + + + + + <% }); %> + <% } %> + +
Site LDAP URLReplication Status
No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).
<%= site.url %> + <% if (site.status === 'Online') { %> + Online + <% } else { %> + <%= site.status %> + <% } %> +
+ +
+
+
+
+
+ +<%- include('footer') %> diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index d0faa08..88dd48b 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -56,6 +56,12 @@ Invites +