d486fb946b
OpenLDAP N-way multi-master replication (docs/replication.md) required an operator to hand-set LDAP_SERVER_ID (unique per site) and LDAP_REPLICATION_HOSTS (every OTHER site's LDAP URL, kept in sync by hand across every node) -- real coordination work, and easy to get wrong or let drift as sites are added. Automates the coordination the master is already in a position to do: - SiteSpoke gets ldapServerId, auto-assigned (next free from 2 upward, 1 reserved for the master) at registration and reused across re-registrations -- same pattern as jump-host's mesh index. - ldapHost is derived from each site's already-known HTTP(S) endpoint (same hostname, port 636) rather than a separately-configured field that could drift from it. - New utils/ldap_replication.js (nextFreeLdapServerId, ldapHostFor), shared between the spoke-facing GET /api/site/ldap-peers (Bearer site join key, returns this caller's own ID + every peer) and the master-local GET /directory-admin/ldap-replication-config (computes its own config directly from SiteSpoke, no HTTP round-trip needed). Verified against real running containers (docker-compose.multisite-e2e.yml): after a real join, the master's computed config correctly includes the spoke as a peer with an assigned ID, and the spoke's own fetched config matches that ID and correctly excludes itself from its own peer list. Known limitation, documented in docs/replication.md: the master's own LDAP_REPLICATION_HOSTS only gets recomputed when ITS setup.sh is re-run (or an admin re-applies it directly) -- there's no live push to an already-running master when a new spoke joins. A spoke's own config is re-checked on every setup.sh run, which is the common/recurring event; the master side is a documented manual step for now rather than a live hot-reload (which would need OpenLDAP's dynamic cn=config backend -- a bigger change, deliberately out of scope here to avoid risking a live directory's LDAP replication on undertested config).
45 lines
1.9 KiB
JavaScript
45 lines
1.9 KiB
JavaScript
require('./setup');
|
|
const { SiteSpoke } = require('../models/site_spoke');
|
|
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
|
|
|
describe('ldap_replication', () => {
|
|
beforeEach(async () => {
|
|
const all = await SiteSpoke.list();
|
|
for (const s of all) await s.delete();
|
|
});
|
|
|
|
describe('ldapHostFor', () => {
|
|
test('derives ldaps://<host>:636 from an http(s) endpoint, ignoring its own port', () => {
|
|
expect(ldapHostFor('https://sso.site2.example.com')).toBe('ldaps://sso.site2.example.com:636');
|
|
expect(ldapHostFor('https://sso.site2.example.com:8443')).toBe('ldaps://sso.site2.example.com:636');
|
|
expect(ldapHostFor('http://sso.site3.example.com')).toBe('ldaps://sso.site3.example.com:636');
|
|
});
|
|
|
|
test('returns null for an unparseable endpoint', () => {
|
|
expect(ldapHostFor('not-a-url')).toBeNull();
|
|
expect(ldapHostFor('')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('nextFreeLdapServerId', () => {
|
|
test('starts at 2 (1 is reserved for the master) when no spokes are registered', async () => {
|
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
|
});
|
|
|
|
test('picks the lowest free id, not just the next highest', async () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await SiteSpoke.create({ id: 'a', endpoint: 'https://a.example.com', pushToken: 'tok-a', created_on: now, ldapServerId: 2 });
|
|
await SiteSpoke.create({ id: 'b', endpoint: 'https://b.example.com', pushToken: 'tok-b', created_on: now, ldapServerId: 4 });
|
|
|
|
await expect(nextFreeLdapServerId()).resolves.toBe(3);
|
|
});
|
|
|
|
test('ignores spokes with no ldapServerId assigned yet', async () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await SiteSpoke.create({ id: 'c', endpoint: 'https://c.example.com', pushToken: 'tok-c', created_on: now });
|
|
|
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
|
});
|
|
});
|
|
});
|