Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3450ef1a8a | |||
| 1c9d9fe405 | |||
| 56e76e1a40 | |||
| 5abd5296f3 | |||
| 5166859f29 | |||
| f05fb27e5e | |||
| 33dfb682f4 | |||
| da60310834 | |||
| 20e9c1dfbe | |||
| 98b2f9f389 | |||
| 90a1d19254 | |||
| 55d6f0b936 | |||
| ba2e905155 | |||
| 455450db1f | |||
| 5ed83a2f59 | |||
| ca812bed8e |
@@ -19,6 +19,11 @@ proxy.env
|
|||||||
# per-deployment and is not committed.
|
# per-deployment and is not committed.
|
||||||
setup.env
|
setup.env
|
||||||
|
|
||||||
|
# spoke.env — same rule as setup.env, but for the join-a-cluster vars split
|
||||||
|
# out for clarity (spoke.env.example IS committed). Holds a real site join
|
||||||
|
# key once filled in.
|
||||||
|
spoke.env
|
||||||
|
|
||||||
# Backup artifacts (hold secrets — the whole user directory + Redis dumps)
|
# Backup artifacts (hold secrets — the whole user directory + Redis dumps)
|
||||||
*.rdb
|
*.rdb
|
||||||
*.ldif
|
*.ldif
|
||||||
|
|||||||
@@ -9,6 +9,97 @@ orchestration code; see each submodule's own `CHANGELOG.md`
|
|||||||
[jump-host](https://github.com/theta42/jump-host/blob/master/CHANGELOG.md))
|
[jump-host](https://github.com/theta42/jump-host/blob/master/CHANGELOG.md))
|
||||||
for what changed inside the apps it composes.
|
for what changed inside the apps it composes.
|
||||||
|
|
||||||
|
## [v2.8.0] - 2026-08-11
|
||||||
|
|
||||||
|
Rolls up **sso-manager-node v2.8.0**. Fixes found while auditing the v2.7.0
|
||||||
|
multi-site work for gaps, plus the published docs staleness that same audit
|
||||||
|
turned up.
|
||||||
|
|
||||||
|
### sso-manager-node v2.8.0
|
||||||
|
- Promotion no longer orphans the demoted old master's LDAP replication --
|
||||||
|
`/demote` now registers itself with the new master immediately instead of
|
||||||
|
being left with no way to ever get a real `ldapServerId` again.
|
||||||
|
- The Directory's site slug and the multi-site replication identity are
|
||||||
|
unified -- previously unrelated values that happened to share a name.
|
||||||
|
- LDAP replication status (real vs. advertised ServerID, a `stale` flag) and
|
||||||
|
per-spoke detail (not just an aggregate count) on the Multi-Site modal.
|
||||||
|
|
||||||
|
### theta-suite docs
|
||||||
|
- Fixed multiple stale/contradictory claims on the published site: the
|
||||||
|
no-inbound relay described as "designed but not automated" (shipped
|
||||||
|
earlier this session), `docs/sso/replication.md` never mentioning the new
|
||||||
|
LDAP MMR auto-config at all, and the homepage feature list only
|
||||||
|
describing the old manual N-way replication.
|
||||||
|
|
||||||
|
## [v2.7.0] - 2026-08-11
|
||||||
|
|
||||||
|
Rolls up **sso-manager-node v2.7.0**. Closes the last "operator hand-sets
|
||||||
|
this" item on the multi-site TODO: OpenLDAP N-way multi-master replication
|
||||||
|
now configures itself.
|
||||||
|
|
||||||
|
### theta-suite orchestration
|
||||||
|
- **`bootstrap/site-ldap-register.js`**: runs on every `setup.sh` invocation
|
||||||
|
(master and spoke), fetches this node's auto-assigned `LDAP_SERVER_ID` +
|
||||||
|
current peer list from sso-manager-node's new endpoints, and restarts
|
||||||
|
`sso-manager` only when the computed config actually changed.
|
||||||
|
- `CFG_LDAP_MMR_MANUAL=true` skips the automatic step for a topology outside
|
||||||
|
this cluster the script can't derive on its own -- otherwise it always
|
||||||
|
runs (every fresh install starts as a master) and would overwrite
|
||||||
|
hand-set values.
|
||||||
|
- `setup.env.example`'s old `#LDAP_SERVER_ID=1`/`#LDAP_REPLICATION_HOSTS=`
|
||||||
|
manual-config prompt is gone for the common case.
|
||||||
|
|
||||||
|
### sso-manager-node v2.7.0
|
||||||
|
- `SiteSpoke.ldapServerId` auto-assigned at registration (same pattern as
|
||||||
|
jump-host's mesh index); each site's LDAP URL derived from its
|
||||||
|
already-known HTTP(S) endpoint. New `GET /api/site/ldap-peers`
|
||||||
|
(spoke-facing) and `GET /directory-admin/ldap-replication-config`
|
||||||
|
(master-local). Verified against real running containers.
|
||||||
|
|
||||||
|
## [v2.6.0] - 2026-08-11
|
||||||
|
|
||||||
|
Rolls up **sso-manager-node v2.6.0** and **jump-host v2.1.1** (already
|
||||||
|
current). Fixes several real bugs found on a live deployment: theta-agent
|
||||||
|
never got its `server_url` written, `theta-agent update` 404'd because
|
||||||
|
setup.sh installed a stale committed binary, the Directory's site slug and
|
||||||
|
gateway count were both wrong, and duplicate group rows accumulated on
|
||||||
|
repeated resource promotion.
|
||||||
|
|
||||||
|
### theta-suite orchestration
|
||||||
|
- **`server_url` now gets written into `/etc/theta42/agent.yml`.** setup.sh's
|
||||||
|
theta-agent install step `sed`'d in `join_key` but never touched
|
||||||
|
`server_url`, so it kept `agent.yml.example`'s literal placeholder forever.
|
||||||
|
Self-heals an already-installed `agent.yml` too (never touches
|
||||||
|
`join_key`/`auth_token`).
|
||||||
|
- **`theta-agent update` no longer 404s.** setup.sh now downloads the current
|
||||||
|
release binary from GitHub instead of trusting one committed in the
|
||||||
|
theta-agent submodule checkout, which predated an upstream fix and could
|
||||||
|
never self-update out of the bug. Matches theta-agent's own `install.sh`.
|
||||||
|
The stale committed binaries were removed from the theta-agent repo.
|
||||||
|
- **`SITE_SLUG` auto-derived from `CFG_SITE_NAME`.** Nothing ever set it
|
||||||
|
before, so a fresh master always showed the app's own literal
|
||||||
|
"site-default" fallback.
|
||||||
|
- **`PROXY_INTERNAL_URL`/`JUMP_INTERNAL_URL` wired into `docker-compose.yml`.**
|
||||||
|
Both the no-inbound relay automation and the new real gateway-mesh count
|
||||||
|
existed in sso-manager-node's code but were completely unreachable in
|
||||||
|
every real deployment -- neither env var was ever actually set.
|
||||||
|
- **`spoke.env.example`** -- a dedicated file for the join-a-cluster vars,
|
||||||
|
split out of `setup.env` for clarity (which still has every option).
|
||||||
|
Layered on top of `setup.env` when present. Also adds `CFG_PUBLIC_DOMAIN`
|
||||||
|
(documented in the spec but never wired into setup.sh before).
|
||||||
|
|
||||||
|
### sso-manager-node v2.6.0
|
||||||
|
- Fixed duplicate access/admin groups accumulating on repeated resource
|
||||||
|
promotion (three independent copies of the same missing-existence-check
|
||||||
|
bug).
|
||||||
|
- `GET /api/directory-admin/resources` no longer runs a full LDAP group
|
||||||
|
self-heal fan-out on every list -- moved to write-time, with an explicit
|
||||||
|
`POST /resources/heal-groups` for backfill.
|
||||||
|
- Recovers an nmap scan that completed successfully despite a benign stderr
|
||||||
|
warning nmap itself prints (`node-nmap` treated it as a fatal failure).
|
||||||
|
- The Multi-Site modal's gateway count now queries jump-host's real mesh
|
||||||
|
registry instead of an unrelated WireGuard subsystem.
|
||||||
|
|
||||||
## [v2.5.0] - 2026-08-10
|
## [v2.5.0] - 2026-08-10
|
||||||
|
|
||||||
Rolls up **sso-manager-node v2.5.0** and **jump-host v2.1.1** — closes the
|
Rolls up **sso-manager-node v2.5.0** and **jump-host v2.1.1** — closes the
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* theta-suite site-ldap-register — runs inside the sso-manager container on
|
||||||
|
* every setup.sh run (both master and spoke) to keep OpenLDAP N-way
|
||||||
|
* multi-master replication config (docs/replication.md) in sync without an
|
||||||
|
* operator hand-maintaining LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS.
|
||||||
|
*
|
||||||
|
* The master assigns each spoke a unique LDAP_SERVER_ID at join time (same
|
||||||
|
* mechanism as jump-host's WireGuard mesh index) and derives every site's
|
||||||
|
* LDAP URL from its already-known HTTPS endpoint -- see sso-manager-node's
|
||||||
|
* GET /api/site/ldap-peers (spoke-facing) and
|
||||||
|
* GET /directory-admin/ldap-replication-config (master-local).
|
||||||
|
*
|
||||||
|
* This script fetches whichever of those two applies to this node's role,
|
||||||
|
* and writes the result to /config/ldap-replication.env (KEY=VALUE, the
|
||||||
|
* same shape setup.env/spoke.env use) if it changed since last run. setup.sh
|
||||||
|
* sources that file before starting sso-manager on every invocation, and
|
||||||
|
* restarts the container when this script reports a change -- OpenLDAP's
|
||||||
|
* static slapd.conf is only read at process start, so a config change needs
|
||||||
|
* a restart to take effect; there's no live push, which is why this has to
|
||||||
|
* be re-run periodically (every setup.sh invocation) rather than working
|
||||||
|
* once at join time and never again, especially on the MASTER, whose peer
|
||||||
|
* list changes every time a new spoke joins.
|
||||||
|
*
|
||||||
|
* docker compose exec sso-manager node /bootstrap/site-ldap-register.js <selfUrl>
|
||||||
|
*
|
||||||
|
* Self-contained (Node built-ins + global fetch), same rule as
|
||||||
|
* bootstrap.js/site-join.js -- does NOT require the SSO's internal models.
|
||||||
|
*
|
||||||
|
* Output (stdout, KEY=VALUE for setup.sh): LDAP_CONFIG_CHANGED=<yes|no>,
|
||||||
|
* LDAP_SERVER_ID=<n>, LDAP_REPLICATION_HOSTS=<space-separated, may be empty>.
|
||||||
|
* Progress logs go to stderr.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SITE_CONFIG = '/config/site.json';
|
||||||
|
const LDAP_CONFIG_FILE = '/config/ldap-replication.env';
|
||||||
|
const SSO_INTERNAL = 'http://localhost:3001';
|
||||||
|
|
||||||
|
const selfUrl = process.argv[2];
|
||||||
|
|
||||||
|
function log(msg) { console.error('[site-ldap-register] ' + msg); }
|
||||||
|
|
||||||
|
function readPersisted() {
|
||||||
|
if (!fs.existsSync(LDAP_CONFIG_FILE)) return { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||||
|
const out = { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||||
|
for (const line of fs.readFileSync(LDAP_CONFIG_FILE, 'utf8').split('\n')) {
|
||||||
|
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
||||||
|
if (m && m[1] in out) out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMasterConfig() {
|
||||||
|
const sso = require('/config/sso-secrets.js');
|
||||||
|
const adminUid = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||||
|
const adminPass = (sso.bootstrap && sso.bootstrap.adminPass) || '';
|
||||||
|
|
||||||
|
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ uid: adminUid, password: adminPass }),
|
||||||
|
});
|
||||||
|
if (!loginRes.ok) throw new Error(`local admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||||
|
const { token } = await loginRes.json();
|
||||||
|
if (!token) throw new Error('local admin login returned no token');
|
||||||
|
|
||||||
|
const cfgRes = await fetch(`${SSO_INTERNAL}/api/directory-admin/ldap-replication-config`, {
|
||||||
|
headers: { 'auth-token': token },
|
||||||
|
});
|
||||||
|
if (!cfgRes.ok) throw new Error(`ldap-replication-config failed (${cfgRes.status}): ${await cfgRes.text().catch(() => '')}`);
|
||||||
|
return cfgRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSpokeConfig(site, selfUrl) {
|
||||||
|
const url = `${site.masterUrl.replace(/\/+$/, '')}/api/site/ldap-peers?endpoint=${encodeURIComponent(selfUrl)}`;
|
||||||
|
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + site.masterJoinKey } });
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
let data = null;
|
||||||
|
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
log('This site is not registered as a spoke on the master yet (join with selfUrl, or re-run site-relay-register.js). Skipping.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`ldap-peers failed (${res.status}): ${(data && data.message) || text}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(SITE_CONFIG)) {
|
||||||
|
log('No /config/site.json yet. Skipping.');
|
||||||
|
console.log('LDAP_CONFIG_CHANGED=no');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const site = JSON.parse(fs.readFileSync(SITE_CONFIG, 'utf8'));
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (site.isMaster) {
|
||||||
|
result = await fetchMasterConfig();
|
||||||
|
} else {
|
||||||
|
if (!site.masterUrl || !site.masterJoinKey) {
|
||||||
|
log('Spoke role but missing masterUrl/masterJoinKey. Skipping.');
|
||||||
|
console.log('LDAP_CONFIG_CHANGED=no');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selfUrl) throw new Error('usage: node /bootstrap/site-ldap-register.js <selfUrl> (required for a spoke)');
|
||||||
|
result = await fetchSpokeConfig(site, selfUrl);
|
||||||
|
if (!result) {
|
||||||
|
console.log('LDAP_CONFIG_CHANGED=no');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = String(result.ldapServerId || '');
|
||||||
|
const hosts = (result.peers || []).map((p) => p.ldapHost).filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const before = readPersisted();
|
||||||
|
const changed = before.LDAP_SERVER_ID !== serverId || before.LDAP_REPLICATION_HOSTS !== hosts;
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
fs.writeFileSync(LDAP_CONFIG_FILE, `LDAP_SERVER_ID=${serverId}\nLDAP_REPLICATION_HOSTS=${hosts}\n`);
|
||||||
|
log(`Replication config changed -- ServerID ${serverId}, ${(result.peers || []).length} peer(s). Wrote ${LDAP_CONFIG_FILE}.`);
|
||||||
|
} else {
|
||||||
|
log(`Replication config unchanged -- ServerID ${serverId}, ${(result.peers || []).length} peer(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`LDAP_CONFIG_CHANGED=${changed ? 'yes' : 'no'}`);
|
||||||
|
console.log(`LDAP_SERVER_ID=${serverId}`);
|
||||||
|
console.log(`LDAP_REPLICATION_HOSTS=${hosts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('[site-ldap-register] FAILED: ' + e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -71,8 +71,19 @@ services:
|
|||||||
# setup.sh (policy sso-broker) — NOT the root token.
|
# setup.sh (policy sso-broker) — NOT the root token.
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NODE_PORT=3001
|
- NODE_PORT=3001
|
||||||
|
# Only a first-run default (site_config.js's envDefaults()) -- a real
|
||||||
|
# join/promote persists its own value to /config/site.json afterward,
|
||||||
|
# which always wins. Derived by setup.sh from CFG_SITE_NAME.
|
||||||
|
- SITE_SLUG=${SITE_SLUG:-}
|
||||||
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
|
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
|
||||||
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
|
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
|
||||||
|
# utils/proxy_client.js (no-inbound relay automation) and
|
||||||
|
# utils/jump_client.js (real mesh-gateway count on the Multi-Site
|
||||||
|
# modal) both no-op/skip without these -- neither was ever actually
|
||||||
|
# wired into the compose environment before, so both features were
|
||||||
|
# unreachable in every real deployment despite existing in code.
|
||||||
|
- PROXY_INTERNAL_URL=http://proxy:3000
|
||||||
|
- JUMP_INTERNAL_URL=http://jump-host:3002
|
||||||
- VAULT_ADDR=http://openbao:8200
|
- VAULT_ADDR=http://openbao:8200
|
||||||
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
|
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
|
||||||
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
|
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
|
||||||
|
|||||||
@@ -256,9 +256,10 @@ See [`AGENT_LOCAL_DISCOVERY_SPEC.md`](./AGENT_LOCAL_DISCOVERY_SPEC.md) — split
|
|||||||
| Join key issuance + one-time directory adoption | **Shipped** — `/api/site/join-keys`, `/api/site/export`, `/api/site/join`, fresh-install-gated (v2.2.0–v2.3.0) |
|
| Join key issuance + one-time directory adoption | **Shipped** — `/api/site/join-keys`, `/api/site/export`, `/api/site/join`, fresh-install-gated (v2.2.0–v2.3.0) |
|
||||||
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
||||||
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
||||||
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0) |
|
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0). Also readable from a dedicated `spoke.env` (`spoke.env.example`, layered on top of `setup.env`) for operators who want join-a-cluster config kept separate from the rest of first-run setup. |
|
||||||
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
||||||
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
||||||
|
| OpenLDAP N-way multi-master replication auto-config | **Shipped** — the master auto-assigns each spoke a unique `LDAP_SERVER_ID` at registration (`SiteSpoke.ldapServerId`, same pattern as jump-host's mesh index) and derives every site's LDAP URL from its already-known HTTPS endpoint; `theta-suite`'s `bootstrap/site-ldap-register.js` applies it, re-checked on every `setup.sh` run since the peer list grows as spokes join. Verified against real running containers. Known gap: the master's own config only updates when ITS `setup.sh` is re-run, not live the moment a new spoke joins (see `docs/replication.md`). |
|
||||||
| Coordinated master promotion (demote the old master as one action) | **Shipped** — `POST /api/site/demote` + `site-promote`'s handoff logic. Fixed two real pre-existing bugs while wiring this in: `site-promote`'s god_admin check read a `req.user.groups` field nothing ever populated (permanently 403'd for everyone), and the read-only write-gate 403'd `site-promote` itself before the handler could run. |
|
| Coordinated master promotion (demote the old master as one action) | **Shipped** — `POST /api/site/demote` + `site-promote`'s handoff logic. Fixed two real pre-existing bugs while wiring this in: `site-promote`'s god_admin check read a `req.user.groups` field nothing ever populated (permanently 403'd for everyone), and the read-only write-gate 403'd `site-promote` itself before the handler could run. |
|
||||||
| WireGuard gateway-to-gateway mesh (`theta-gateway`) | **Shipped** — `POST /api/mesh/register`/`/join` (join-token bootstrap), `utils/wg_iface.js` (kernel WireGuard, falls back to userspace `wireguard-go`). Verified with a real two-container test: actual encrypted tunnel, real ICMP traffic across it, 0% loss. `wg_iface.removePeer()` also cleans up the kernel routes `setPeer()` added (verified live: routes present after `setPeer`, gone after `removePeer`, own local route untouched), and `DELETE /api/mesh/gateways/:id` exposes it from the mesh UI. |
|
| WireGuard gateway-to-gateway mesh (`theta-gateway`) | **Shipped** — `POST /api/mesh/register`/`/join` (join-token bootstrap), `utils/wg_iface.js` (kernel WireGuard, falls back to userspace `wireguard-go`). Verified with a real two-container test: actual encrypted tunnel, real ICMP traffic across it, 0% loss. `wg_iface.removePeer()` also cleans up the kernel routes `setPeer()` added (verified live: routes present after `setPeer`, gone after `removePeer`, own local route untouched), and `DELETE /api/mesh/gateways/:id` exposes it from the mesh UI. |
|
||||||
| Cross-component routing (replication over the mesh) | **Shipped** — `utils/site_replicate.js` tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) when pushing resync pings; a spoke with no `meshIp` on file behaves exactly as before. |
|
| Cross-component routing (replication over the mesh) | **Shipped** — `utils/site_replicate.js` tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) when pushing resync pings; a spoke with no `meshIp` on file behaves exactly as before. |
|
||||||
|
|||||||
+6
-4
@@ -74,10 +74,12 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
|||||||
management built directly into the web interface, allowing operators to drive
|
management built directly into the web interface, allowing operators to drive
|
||||||
system administration and automation pipelines programmatically without an
|
system administration and automation pipelines programmatically without an
|
||||||
active browser session.
|
active browser session.
|
||||||
- **Multi-Site Geo-Replication**: Built-in support for N-Way Multi-Master LDAP
|
- **Multi-Site Geo-Replication**: A master site and any number of spokes join
|
||||||
replication, allowing directory states to sync across geographically separated
|
with one key and one URL, staying in sync automatically (no manual LDAP
|
||||||
physical hardware or remote data centers for high availability and low-latency
|
config) — see [Multi-Site (Master/Spoke Join)](sso/multi-site.html). Raw
|
||||||
local reads.
|
N-Way Multi-Master LDAP replication is also available directly for
|
||||||
|
deployments that want every site independently writable with no
|
||||||
|
master/spoke concept — see [Geo-Location Scaling](sso/replication.html).
|
||||||
- **Multi-Target Load Balancing**: Native reverse-proxy load balancing that
|
- **Multi-Target Load Balancing**: Native reverse-proxy load balancing that
|
||||||
distributes traffic across multiple application backends using customizable
|
distributes traffic across multiple application backends using customizable
|
||||||
health checks and round-robin strategies.
|
health checks and round-robin strategies.
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ file shape.
|
|||||||
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
|
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
|
||||||
> secrets — no need to write a `setup.env`.
|
> secrets — no need to write a `setup.env`.
|
||||||
|
|
||||||
|
> **Joining an existing Theta Directory cluster instead of seeding a fresh
|
||||||
|
> one?** See [Multi-Site (Master/Spoke Join)](sso/multi-site.html) —
|
||||||
|
> `spoke.env.example` has the join-a-cluster vars split out into their own
|
||||||
|
> file, or set them directly in `setup.env` (which has every option).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Run
|
## 3. Run
|
||||||
|
|||||||
+49
-19
@@ -10,10 +10,10 @@ the **master** (single write authority for the shared catalog) and any
|
|||||||
number of **spokes** — read-only replicas that stay in sync automatically and
|
number of **spokes** — read-only replicas that stay in sync automatically and
|
||||||
run local authentication with zero WAN dependency.
|
run local authentication with zero WAN dependency.
|
||||||
|
|
||||||
This is a different, higher-level mechanism than [raw LDAP N-way
|
This is a higher-level mechanism than [raw LDAP N-way
|
||||||
replication](replication.html) — see [How this relates to LDAP
|
replication](replication.html) — and it now drives that lower-level
|
||||||
replication](#how-this-relates-to-ldap-replication) below if you're deciding
|
replication for you automatically. See [How this relates to LDAP
|
||||||
between the two.
|
replication](#how-this-relates-to-ldap-replication) below.
|
||||||
|
|
||||||
## Why and when to use this
|
## Why and when to use this
|
||||||
|
|
||||||
@@ -38,8 +38,20 @@ between the two.
|
|||||||
admin, no enrolled agents), either:
|
admin, no enrolled agents), either:
|
||||||
- Paste the master's URL and the join key into the Master Site modal's
|
- Paste the master's URL and the join key into the Master Site modal's
|
||||||
**Join an Existing Site** form, or
|
**Join an Existing Site** form, or
|
||||||
- Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` in
|
- Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` before
|
||||||
`setup.env` before the first `./setup.sh` run.
|
the first `./setup.sh` run -- either in `setup.env` (which has every
|
||||||
|
option), or in a dedicated `spoke.env` (`cp spoke.env.example spoke.env`)
|
||||||
|
if you'd rather keep join-a-cluster config separate from the rest of the
|
||||||
|
stack's setup. Both are read; `spoke.env`'s values win on a conflict.
|
||||||
|
No public IP on this site at all? `spoke.env.example` also covers the
|
||||||
|
no-inbound relay vars (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`).
|
||||||
|
|
||||||
|
Want this spoke reachable at its own public domain rather than sharing
|
||||||
|
the master's? `CFG_DOMAIN` (the LDAP identity namespace) must stay
|
||||||
|
identical across every site in a cluster — MMR replicas can't diverge
|
||||||
|
on base DN — but `CFG_PUBLIC_DOMAIN` overrides just this site's own web
|
||||||
|
hostnames (`sso.*`/`proxy.*`) independently of it. Only meaningful for
|
||||||
|
an inbound spoke serving its own traffic directly.
|
||||||
3. The spoke pulls the master's full export (LDAP tree, resource catalog,
|
3. The spoke pulls the master's full export (LDAP tree, resource catalog,
|
||||||
agent-signing key) and adopts it, then registers its own reachable URL
|
agent-signing key) and adopts it, then registers its own reachable URL
|
||||||
with the master so it can receive live updates going forward.
|
with the master so it can receive live updates going forward.
|
||||||
@@ -77,26 +89,44 @@ so on) are **not** currently synced — each site still generates its own.
|
|||||||
|
|
||||||
- Both sites need a network path to each other's HTTP(S) API — the master to
|
- Both sites need a network path to each other's HTTP(S) API — the master to
|
||||||
pull an export from, the spoke to push replication updates back to. A site
|
pull an export from, the spoke to push replication updates back to. A site
|
||||||
with no inbound path at all (e.g. behind CGNAT) can't join yet on its own;
|
with **no inbound path at all** (e.g. behind CGNAT) can still join: set
|
||||||
a relay mechanism for that case is designed but not automated (see the
|
`CFG_SPOKE_NO_INBOUND=true` + `CFG_SPOKE_PUBLIC_HOST` (`spoke.env.example`)
|
||||||
[architecture spec](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
once its jump-host is meshed to the master's over WireGuard (mesh peering
|
||||||
for the current status).
|
itself is a manual, one-time step on both jump-hosts — see [Theta Gateway
|
||||||
|
→ Mesh](../jump-host/mesh.html)) — the master then relays traffic to it
|
||||||
|
and auto-creates the matching route on its own `theta-proxy`. A spoke with
|
||||||
|
**zero inbound and zero outbound** path still can't join at all (the join
|
||||||
|
itself needs to reach the master's API directly).
|
||||||
- Joining only ever happens on a **fresh install**. There's no way to merge
|
- Joining only ever happens on a **fresh install**. There's no way to merge
|
||||||
an already-populated directory into a master's — re-provision the host
|
an already-populated directory into a master's — re-provision the host
|
||||||
first.
|
first.
|
||||||
|
- Promoting a spoke to master doesn't instantly finish reconciling OpenLDAP
|
||||||
|
replication (see below) — re-run `setup.sh` on the newly-promoted node
|
||||||
|
promptly afterward.
|
||||||
|
|
||||||
## How this relates to LDAP replication
|
## How this relates to LDAP replication
|
||||||
|
|
||||||
[N-way LDAP replication](replication.html) is a *lower-level*, different
|
[N-way LDAP replication](replication.html) is the *lower-level* mechanism
|
||||||
mechanism: every site runs a fully independent, fully writable `slapd`, wired
|
underneath this: `slapd`'s own `syncrepl`, wired via `LDAP_SERVER_ID` +
|
||||||
together with raw `syncrepl` environment variables, and there's no concept of
|
`LDAP_REPLICATION_HOSTS`. Originally this was hand-configured by the
|
||||||
a master or a managed join. It predates this feature and is still there for
|
operator, separately from the join flow above, for deployments that wanted
|
||||||
deployments that specifically want every site independently writable.
|
every site independently writable with no concept of a master.
|
||||||
|
|
||||||
Multi-site join (this page) is the opposite design: one write authority, a
|
**When you join via this page's flow, that lower-level config is now handled
|
||||||
managed onboarding flow, and automatic ongoing sync — closer to what most
|
for you.** The master auto-assigns each spoke a unique `LDAP_SERVER_ID` at
|
||||||
"add a second office" or "add a home-lab spoke" setups actually want. **Don't
|
join time and derives every site's LDAP URL from its already-known HTTPS
|
||||||
combine the two** — pick one per deployment.
|
endpoint — `theta-suite`'s `bootstrap/site-ldap-register.js` applies it,
|
||||||
|
re-checked on every `setup.sh` run since the peer list grows as spokes join.
|
||||||
|
You don't hand-set `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a cluster
|
||||||
|
built this way. See [Geo-Location Scaling](replication.html#automatic-config-via-multi-site-join)
|
||||||
|
for the mechanics, and its documented limitation: the *master's* own
|
||||||
|
replication list only updates on ITS next `setup.sh` run, not live the
|
||||||
|
instant a new spoke joins.
|
||||||
|
|
||||||
|
Still want fully independent, always-writable sites with no master/spoke
|
||||||
|
concept at all? `CFG_LDAP_MMR_MANUAL=true` opts out of the automatic path so
|
||||||
|
you can hand-set `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` directly, same as
|
||||||
|
before this integration existed.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|||||||
+34
-9
@@ -23,33 +23,58 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
The container's entrypoint reads two environment variables to configure this
|
||||||
|
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||||
|
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||||
|
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||||
|
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||||
|
in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
### Automatic config via Multi-Site join
|
||||||
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
|
**If you're using [Multi-Site join](multi-site.html) (`CFG_MASTER_DIRECTORY_URL`/`spoke.env`),
|
||||||
|
you don't set these by hand.** The master assigns each spoke a unique
|
||||||
|
`LDAP_SERVER_ID` at join time (the same way it assigns a WireGuard mesh
|
||||||
|
index) and derives `LDAP_REPLICATION_HOSTS` from every site's already-known
|
||||||
|
HTTPS endpoint (`ldaps://<same-host>:636`) -- `theta-suite`'s `bootstrap/
|
||||||
|
site-ldap-register.js` applies it and re-checks on every `setup.sh` run,
|
||||||
|
since the peer list grows as new spokes join, restarting `sso-manager` only
|
||||||
|
when the computed config actually changed.
|
||||||
|
|
||||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
**Known limitation**: the *master's* own `LDAP_REPLICATION_HOSTS` only gets
|
||||||
|
recomputed when its `setup.sh` is re-run — there's no live push telling an
|
||||||
|
already-running master about a spoke that joined five minutes ago. Re-run
|
||||||
|
`setup.sh` on the master after bringing up a new spoke (or after promoting
|
||||||
|
one to master) to pick up the current peer list. A spoke's own config, by
|
||||||
|
contrast, is re-checked and applied on every `setup.sh` run there, which is
|
||||||
|
the common/recurring event.
|
||||||
|
|
||||||
|
### Manual configuration
|
||||||
|
|
||||||
|
Have a topology outside a `theta-suite`-managed cluster (fully independent,
|
||||||
|
always-writable sites, no master/spoke concept)? Set
|
||||||
|
`CFG_LDAP_MMR_MANUAL=true` to skip the automatic path entirely and set the
|
||||||
|
two variables directly -- without this, the automatic step runs on every
|
||||||
|
deployment (every fresh install starts as a master) and will overwrite them.
|
||||||
|
|
||||||
|
**Site 1**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=1
|
LDAP_SERVER_ID=1
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
**Site 2**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=2
|
LDAP_SERVER_ID=2
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
**Site 3**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=3
|
LDAP_SERVER_ID=3
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
+37
-7
@@ -61,9 +61,19 @@ CFG_DOMAIN=example.com
|
|||||||
# being a one-time snapshot -- the master must be able to reach THIS site's
|
# being a one-time snapshot -- the master must be able to reach THIS site's
|
||||||
# CFG_SSO_HOST for that part to work; if it can't (this site has no inbound
|
# CFG_SSO_HOST for that part to work; if it can't (this site has no inbound
|
||||||
# path), the join still succeeds, it just never receives live updates.
|
# path), the join still succeeds, it just never receives live updates.
|
||||||
|
#
|
||||||
|
# All of these (and the no-inbound relay pair below) also live in their own
|
||||||
|
# spoke.env.example, if you'd rather keep join-a-cluster config in a
|
||||||
|
# dedicated file instead of here -- both are read, spoke.env's values win.
|
||||||
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||||
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||||
|
|
||||||
|
# This site's own public web domain, independent of CFG_DOMAIN above (the
|
||||||
|
# shared LDAP identity namespace, which must be identical across every site).
|
||||||
|
# Optional -- only meaningful for an inbound spoke/standalone site that wants
|
||||||
|
# its own domain rather than sharing the master's.
|
||||||
|
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||||
|
|
||||||
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||||
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||||
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||||
@@ -78,6 +88,20 @@ CFG_DOMAIN=example.com
|
|||||||
#CFG_SPOKE_NO_INBOUND=true
|
#CFG_SPOKE_NO_INBOUND=true
|
||||||
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||||
|
|
||||||
|
# Two service-to-service integrations the Directory uses (both reuse each
|
||||||
|
# app's existing self-service API token system -- see MULTI_SITE_SPEC.md's
|
||||||
|
# "service-to-service auth" note -- not a new credential type each):
|
||||||
|
# - No-inbound relay automation (above) needs a theta-proxy API token so
|
||||||
|
# sso-manager can create/update the relay Host route on its own.
|
||||||
|
# - The Multi-Site modal's real gateway-mesh count needs a jump-host API
|
||||||
|
# token (minted by a jump-admin user) to read GET /api/mesh/gateways.
|
||||||
|
# Neither is required for the rest of the stack to work -- both features
|
||||||
|
# just report "not configured" until you mint a token in each app's own web
|
||||||
|
# UI (Settings -> API Tokens) and store it in OpenBao, from inside the
|
||||||
|
# sso-manager container (VAULT_ADDR/VAULT_TOKEN are already set there):
|
||||||
|
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-proxy', {token: 'prx_...'})"
|
||||||
|
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-jump', {token: 'jmp_...'})"
|
||||||
|
|
||||||
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
|
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
|
||||||
# For an isolated/offline/corporate-network test host that only reaches the
|
# For an isolated/offline/corporate-network test host that only reaches the
|
||||||
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
|
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
|
||||||
@@ -128,14 +152,20 @@ CFG_DOMAIN=example.com
|
|||||||
#CFG_THETA_AGENT_FULL_CONTROL=1
|
#CFG_THETA_AGENT_FULL_CONTROL=1
|
||||||
|
|
||||||
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
|
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
|
||||||
# If deploying this stack across multiple physical sites to provide local HA
|
# If you're joining a directory cluster (CFG_MASTER_DIRECTORY_URL/spoke.env
|
||||||
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
|
# above), N-Way Multi-Master OpenLDAP replication is configured for you
|
||||||
# This requires assigning a unique ID to each site and listing the LDAPS URLs
|
# automatically -- setup.sh's bootstrap/site-ldap-register.js asks the master
|
||||||
# of all OTHER sites in the cluster.
|
# for a unique LDAP_SERVER_ID and the current list of every other site's LDAP
|
||||||
|
# URL on every run (see docs/replication.md), restarting sso-manager only
|
||||||
|
# when that config actually changed. Nothing to set here for the common case.
|
||||||
#
|
#
|
||||||
# Each site MUST have a unique LDAP_SERVER_ID (e.g. 1, 2, 3).
|
# Have a manually-coordinated LDAP MMR topology this script can't derive on
|
||||||
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
|
# its own (e.g. peers outside this theta-suite cluster)? Set
|
||||||
# Example for Site 1:
|
# CFG_LDAP_MMR_MANUAL=true to skip the automatic step entirely and set
|
||||||
|
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS directly -- without this, the
|
||||||
|
# automatic step runs on every deployment (every fresh install starts as a
|
||||||
|
# master) and will overwrite them.
|
||||||
|
#CFG_LDAP_MMR_MANUAL=true
|
||||||
#LDAP_SERVER_ID=1
|
#LDAP_SERVER_ID=1
|
||||||
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
|
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -200,6 +200,10 @@ fi
|
|||||||
# later steps can use it. The authoritative CFG_* for secrets are still
|
# later steps can use it. The authoritative CFG_* for secrets are still
|
||||||
# resolved in ensure_config; this is only the hostname override.
|
# resolved in ensure_config; this is only the hostname override.
|
||||||
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
||||||
|
# spoke.env (optional, see spoke.env.example): the join-a-cluster vars split
|
||||||
|
# out of setup.env for clarity, layered on top so its values win over any
|
||||||
|
# same-named ones in setup.env. Same first-run-only rule as setup.env below.
|
||||||
|
[[ -f ./spoke.env ]] && parse_kv_file ./spoke.env
|
||||||
export CFG_JUMP_HOST
|
export CFG_JUMP_HOST
|
||||||
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
||||||
export CFG_CREATE_ALL_HTTP
|
export CFG_CREATE_ALL_HTTP
|
||||||
@@ -467,6 +471,10 @@ BAOEOF
|
|||||||
info "Reading domain/hosts from ./setup.env ..."
|
info "Reading domain/hosts from ./setup.env ..."
|
||||||
parse_kv_file ./setup.env
|
parse_kv_file ./setup.env
|
||||||
fi
|
fi
|
||||||
|
if [[ -f ./spoke.env ]]; then
|
||||||
|
info "Reading multi-site join config from ./spoke.env ..."
|
||||||
|
parse_kv_file ./spoke.env
|
||||||
|
fi
|
||||||
|
|
||||||
# Bind the CFG_* vars to empty where setup.env / the environment didn't set
|
# Bind the CFG_* vars to empty where setup.env / the environment didn't set
|
||||||
# them, so the .env migration's `${LDAP_X:-$CFG_X}` defaults below don't trip
|
# them, so the .env migration's `${LDAP_X:-$CFG_X}` defaults below don't trip
|
||||||
@@ -540,9 +548,25 @@ BAOEOF
|
|||||||
[[ -n "$CFG_DOMAIN" ]] \
|
[[ -n "$CFG_DOMAIN" ]] \
|
||||||
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
|
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
|
||||||
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
|
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
|
||||||
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
|
# CFG_PUBLIC_DOMAIN (MULTI_SITE_SPEC.md §4): an inbound spoke's own public
|
||||||
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
|
# web domain, independent of CFG_DOMAIN. CFG_DOMAIN is the LDAP identity
|
||||||
|
# namespace and MUST be identical across every site (MMR replicas can't
|
||||||
|
# diverge on base DN) -- CFG_PUBLIC_DOMAIN only changes where the web
|
||||||
|
# hostnames point, never the DN. Unset (the default): behaves exactly as
|
||||||
|
# before, hostnames derive from CFG_DOMAIN like any standalone install.
|
||||||
|
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.${CFG_PUBLIC_DOMAIN:-$CFG_DOMAIN}}"
|
||||||
|
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.${CFG_PUBLIC_DOMAIN:-$CFG_DOMAIN}}"
|
||||||
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
|
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
|
||||||
|
# Multi-site identity (site_config.js's `siteSlug`, shown on the Directory's
|
||||||
|
# Multi-Site modal) -- without this it's never set anywhere and every fresh
|
||||||
|
# master shows the module's own literal fallback, "site-default", forever.
|
||||||
|
# Derived from CFG_SITE_NAME with the same slugify rule bootstrap.js uses
|
||||||
|
# for the site Resource's own slug (site_$(slugify), underscore prefix --
|
||||||
|
# this is hyphenated to match site_config.js's own "site-default" format).
|
||||||
|
# site.json overrides this after first bring-up (join/promote write real
|
||||||
|
# values there), so this only ever matters for a fresh install.
|
||||||
|
SITE_SLUG="site-$(echo "$CFG_SITE_NAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')"
|
||||||
|
export SITE_SLUG
|
||||||
CFG_ORG="${CFG_ORG:-Theta Directory}"
|
CFG_ORG="${CFG_ORG:-Theta Directory}"
|
||||||
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
|
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
|
||||||
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
|
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
|
||||||
@@ -1036,6 +1060,15 @@ info "Starting bao-renewer (service-token renewal sidecar)..."
|
|||||||
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
export SSO_GIT_COMMIT
|
export SSO_GIT_COMMIT
|
||||||
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
|
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
|
||||||
|
# OpenLDAP multi-master replication (docs/replication.md, auto-configured --
|
||||||
|
# see step 7e below and bootstrap/site-ldap-register.js): pick up whatever
|
||||||
|
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS a PRIOR run already computed, so a
|
||||||
|
# restart doesn't silently drop back to standalone (no LDAP_SERVER_ID env at
|
||||||
|
# all). A truly fresh install has no file yet -- that's fine, it just starts
|
||||||
|
# standalone until step 7e computes and applies real values. Skipped under
|
||||||
|
# CFG_LDAP_MMR_MANUAL=true so a manually hand-set LDAP_SERVER_ID/
|
||||||
|
# LDAP_REPLICATION_HOSTS in setup.env isn't clobbered by a stale auto file.
|
||||||
|
[[ "${CFG_LDAP_MMR_MANUAL:-false}" != "true" && -f "$CONFIG_DIR/ldap-replication.env" ]] && parse_kv_file "$CONFIG_DIR/ldap-replication.env"
|
||||||
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
|
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
|
||||||
"${COMPOSE[@]}" up -d --build sso-manager
|
"${COMPOSE[@]}" up -d --build sso-manager
|
||||||
|
|
||||||
@@ -1298,6 +1331,40 @@ if [[ "${CFG_SPOKE_NO_INBOUND:-false}" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── 7e. OpenLDAP multi-master replication auto-config (every run) ────────────
|
||||||
|
# docs/replication.md: the master assigns each spoke a unique LDAP_SERVER_ID
|
||||||
|
# and derives every site's LDAP URL automatically (bootstrap/
|
||||||
|
# site-ldap-register.js) instead of an operator hand-maintaining
|
||||||
|
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS. Runs on every invocation -- both
|
||||||
|
# master (its peer list grows as spokes join) and spoke -- and restarts
|
||||||
|
# sso-manager only when the computed config actually changed, since
|
||||||
|
# OpenLDAP's static slapd.conf is only read at process start.
|
||||||
|
#
|
||||||
|
# CFG_LDAP_MMR_MANUAL=true skips this entirely -- every fresh install starts
|
||||||
|
# as a master, so without this escape hatch an operator's own hand-set
|
||||||
|
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS (a topology outside this theta-suite
|
||||||
|
# cluster this script can't derive) would get silently overwritten.
|
||||||
|
if [[ "${CFG_LDAP_MMR_MANUAL:-false}" == "true" ]]; then
|
||||||
|
info "CFG_LDAP_MMR_MANUAL=true — skipping automatic LDAP replication config."
|
||||||
|
LDAP_REG_OUT=""
|
||||||
|
else
|
||||||
|
LDAP_REG_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-ldap-register.js "https://$CFG_SSO_HOST" 2>&1) || warn "LDAP replication config check failed — check: ${COMPOSE[*]} exec sso-manager node /bootstrap/site-ldap-register.js https://$CFG_SSO_HOST"
|
||||||
|
echo "$LDAP_REG_OUT" | sed 's/^/[setup] /'
|
||||||
|
fi
|
||||||
|
if echo "$LDAP_REG_OUT" | grep -q '^LDAP_CONFIG_CHANGED=yes'; then
|
||||||
|
info "LDAP replication config changed — restarting sso-manager to apply it..."
|
||||||
|
[[ -f "$CONFIG_DIR/ldap-replication.env" ]] && parse_kv_file "$CONFIG_DIR/ldap-replication.env"
|
||||||
|
"${COMPOSE[@]}" up -d --force-recreate sso-manager
|
||||||
|
info "Waiting for sso-manager to be healthy again..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
|
||||||
|
info "sso-manager is healthy."; break
|
||||||
|
fi
|
||||||
|
if (( i == 60 )); then warn "sso-manager did not become healthy in 120s after the LDAP config restart. Check: ${COMPOSE[*]} logs sso-manager"; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
|
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
|
||||||
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
|
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
|
||||||
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
|
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
|
||||||
@@ -1305,16 +1372,28 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
|||||||
info "Setting up theta-agent on the host..."
|
info "Setting up theta-agent on the host..."
|
||||||
(
|
(
|
||||||
cd theta-agent || exit 0
|
cd theta-agent || exit 0
|
||||||
# Install the prebuilt binary that ships in the theta-agent submodule (the
|
# Download the current release binary from GitHub rather than trusting a
|
||||||
# repo's own install.sh uses the same release binary). We do NOT build from
|
# binary committed in the submodule checkout. A committed binary drifts:
|
||||||
# source here: a previous `go build -o theta-agent main.go websocket.go
|
# theta-agent's own `theta-agent update` moved to pulling from GitHub
|
||||||
# config.go` omitted executor.go/telemetry.go, failed to compile, and was
|
# Releases (DESIGN-WINDOWS.md §9, "nothing binary lives in the repos")
|
||||||
# silently skipped, so the agent was never installed.
|
# once, but this script kept installing the stale binary that shipped
|
||||||
if [[ ! -f "theta-agent-linux-amd64" ]]; then
|
# with an old submodule pin, which still pointed `update` at a dead
|
||||||
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
|
# SSO /resources/ URL that never existed server-side -- so an agent
|
||||||
|
# installed this way could never even self-update out of the bug. We
|
||||||
|
# do NOT build from source here either: a previous `go build -o
|
||||||
|
# theta-agent main.go websocket.go config.go` omitted
|
||||||
|
# executor.go/telemetry.go, failed to compile, and was silently
|
||||||
|
# skipped, so the agent was never installed.
|
||||||
|
AGENT_BIN_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||||
|
AGENT_BIN_TMP="$(mktemp)"
|
||||||
|
info " Downloading latest theta-agent-linux-amd64 release binary..."
|
||||||
|
if ! curl -fsSL -o "$AGENT_BIN_TMP" "$AGENT_BIN_URL" || [[ ! -s "$AGENT_BIN_TMP" ]]; then
|
||||||
|
rm -f "$AGENT_BIN_TMP"
|
||||||
|
warn "Could not download theta-agent-linux-amd64 from $AGENT_BIN_URL. Skipping theta-agent installation."
|
||||||
else
|
else
|
||||||
info " Installing prebuilt theta-agent binary..."
|
chmod +x "$AGENT_BIN_TMP"
|
||||||
if [[ -x "theta-agent-linux-amd64" ]]; then
|
info " Installing theta-agent binary..."
|
||||||
|
if true; then
|
||||||
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
|
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
|
||||||
sudo mkdir -p /etc/theta42
|
sudo mkdir -p /etc/theta42
|
||||||
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
||||||
@@ -1345,7 +1424,12 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
|||||||
else
|
else
|
||||||
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
||||||
fi
|
fi
|
||||||
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP.
|
||||||
|
# Without this, agent.yml keeps agent.yml.example's literal
|
||||||
|
# "https://sso.example.com" placeholder forever -- nothing
|
||||||
|
# else in this block ever touched server_url, only join_key.
|
||||||
|
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
||||||
|
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
||||||
sudo getent group theta-secrets >/dev/null 2>&1 || sudo groupadd -r theta-secrets 2>/dev/null || true
|
sudo getent group theta-secrets >/dev/null 2>&1 || sudo groupadd -r theta-secrets 2>/dev/null || true
|
||||||
sudo getent group theta >/dev/null 2>&1 || sudo groupadd -r theta 2>/dev/null || true
|
sudo getent group theta >/dev/null 2>&1 || sudo groupadd -r theta 2>/dev/null || true
|
||||||
SECRETS_GRP="root"
|
SECRETS_GRP="root"
|
||||||
@@ -1353,12 +1437,25 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
|||||||
sudo chown -R "root:$SECRETS_GRP" /etc/theta42 2>/dev/null || true
|
sudo chown -R "root:$SECRETS_GRP" /etc/theta42 2>/dev/null || true
|
||||||
sudo chmod 750 /etc/theta42
|
sudo chmod 750 /etc/theta42
|
||||||
sudo chmod 640 /etc/theta42/agent.yml
|
sudo chmod 640 /etc/theta42/agent.yml
|
||||||
|
else
|
||||||
|
# Self-heal an already-installed agent.yml that predates the
|
||||||
|
# server_url fix above -- it would otherwise keep whatever
|
||||||
|
# placeholder/stale host it was first installed with
|
||||||
|
# forever, since nothing else in this script ever revisits
|
||||||
|
# an existing agent.yml. Never touches join_key/auth_token:
|
||||||
|
# those may since have been rewritten by the agent itself
|
||||||
|
# with real issued credentials.
|
||||||
|
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
||||||
|
if sudo grep -q '^server_url:' /etc/theta42/agent.yml; then
|
||||||
|
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# Stop a running agent before overwriting its binary (cp into a
|
# Stop a running agent before overwriting its binary (cp into a
|
||||||
# running executable fails with "Text file busy" on a re-install).
|
# running executable fails with "Text file busy" on a re-install).
|
||||||
sudo systemctl stop theta-agent.service 2>/dev/null || true
|
sudo systemctl stop theta-agent.service 2>/dev/null || true
|
||||||
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
|
sudo cp "$AGENT_BIN_TMP" /usr/local/bin/theta-agent
|
||||||
sudo chmod +x /usr/local/bin/theta-agent
|
sudo chmod +x /usr/local/bin/theta-agent
|
||||||
|
rm -f "$AGENT_BIN_TMP"
|
||||||
|
|
||||||
# Install desktop tray companion if available
|
# Install desktop tray companion if available
|
||||||
TRAY_SRC="dist/theta-agent-tray-linux-amd64"
|
TRAY_SRC="dist/theta-agent-tray-linux-amd64"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# spoke.env — join this stack to an existing Theta Directory as a read-only
|
||||||
|
# spoke, instead of seeding a fresh master (MULTI_SITE_SPEC.md).
|
||||||
|
#
|
||||||
|
# This is the ONE place the join-a-cluster vars live -- split out of
|
||||||
|
# setup.env.example (which still has every option, including these, for a
|
||||||
|
# single-file bring-up) purely for clarity: standing up a spoke is a distinct
|
||||||
|
# operation from configuring a fresh install, so it gets its own small file
|
||||||
|
# instead of being buried among unrelated options. Set what you need here;
|
||||||
|
# everything else (domain, admin creds, SMTP, ...) still comes from setup.env
|
||||||
|
# as normal -- copy setup.env.example too and fill in CFG_DOMAIN there first.
|
||||||
|
#
|
||||||
|
# Same first-run-only rule as setup.env: read once (layered on top of
|
||||||
|
# setup.env, so a var set in both places takes this file's value), then
|
||||||
|
# ignored once ./config/ exists -- an already-running directory can never be
|
||||||
|
# merged into a master's this way. The one exception is the no-inbound relay
|
||||||
|
# vars at the bottom, which setup.sh re-checks on every run (see their
|
||||||
|
# comment) since mesh peering usually finishes after the first bring-up.
|
||||||
|
#
|
||||||
|
# cp setup.env.example setup.env # if you haven't already -- set CFG_DOMAIN
|
||||||
|
# cp spoke.env.example spoke.env
|
||||||
|
# $EDITOR spoke.env # set CFG_MASTER_DIRECTORY_URL + _JOIN_KEY below
|
||||||
|
# ./setup.sh
|
||||||
|
#
|
||||||
|
# Copying this file to spoke.env (gitignored) keeps your join key out of git.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# The master's URL and a site join key. Mint a key on the master:
|
||||||
|
# Directory -> the Master Site modal -> Site Join Keys -> Mint key.
|
||||||
|
# Both required to join; if either is unset this stack seeds a fresh master
|
||||||
|
# instead (setup.env.example's normal behavior).
|
||||||
|
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||||
|
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||||
|
|
||||||
|
# This spoke's own public web domain, if it needs one independent of the
|
||||||
|
# master's (an inbound spoke serving its own traffic directly -- see
|
||||||
|
# CFG_SPOKE_NO_INBOUND below for the opposite case). Optional: CFG_DOMAIN
|
||||||
|
# (in setup.env) is the shared LDAP identity namespace and must be identical
|
||||||
|
# across every site in the cluster -- this only changes where THIS site's own
|
||||||
|
# web hostnames (sso.*, proxy.*) point, never the LDAP base DN.
|
||||||
|
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||||
|
|
||||||
|
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||||
|
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||||
|
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||||
|
# manual, out-of-band step on BOTH jump-hosts (mint a mesh join token on the
|
||||||
|
# master's jump-host, paste it into this site's jump-host "Join a mesh" UI
|
||||||
|
# action) that can't run unattended inside this script. Once that's done, set
|
||||||
|
# these two and re-run setup.sh: it discovers this jump-host's assigned mesh
|
||||||
|
# IP and registers it with the master, which then auto-creates the relay
|
||||||
|
# route on its own theta-proxy. Safe to leave set before meshing -- setup.sh
|
||||||
|
# just reports "not meshed yet" and skips until a later re-run finds the IP.
|
||||||
|
#CFG_SPOKE_NO_INBOUND=true
|
||||||
|
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||||
+1
-1
Submodule sso-manager-node updated: b6a82d58d5...7efd3fd6bd
+1
-1
Submodule theta-agent updated: 1b332cacab...ef9d4b004b
Reference in New Issue
Block a user