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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user