feat: Add documentation and Sites status dashboard page
This commit is contained in:
@@ -56,6 +56,7 @@ backend, that's the niche.
|
|||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
Emby, …) uses LDAPS/StartTLS against the same directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
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
|
## Get it
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -145,6 +145,45 @@ router.get('/token', function(req, res, next) {
|
|||||||
res.render('token', {...values});
|
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){
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
||||||
let token = await PasswordResetToken.get(req.params.token);
|
let token = await PasswordResetToken.get(req.params.token);
|
||||||
|
|||||||
@@ -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') %>
|
||||||
@@ -56,6 +56,12 @@
|
|||||||
Invites
|
Invites
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item group-required group-required-app_sso_admin">
|
||||||
<a class="nav-link" href="/dashboard">
|
<a class="nav-link" href="/dashboard">
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<i class="fa-solid fa-gauge-high"></i>
|
||||||
|
|||||||
Reference in New Issue
Block a user