Compare commits

..

12 Commits

Author SHA1 Message Date
wmantly 5166859f29 Merge pull request #216 from theta42/release-v2.7.0
CI/CD / docker-push (push) Has been skipped
CI/CD / build-theta-agent (push) Successful in 42s
release(v2.7.0): auto-configured OpenLDAP multi-master replication
2026-08-10 20:10:37 -07:00
wmantly f05fb27e5e release(v2.7.0): auto-configured OpenLDAP multi-master replication 2026-08-10 23:08:49 -04:00
wmantly 33dfb682f4 Merge pull request #215 from theta42/feat-ldap-mmr-auto-config
feat(multi-site): auto-configure OpenLDAP replication on join/every run
2026-08-10 20:03:04 -07:00
wmantly da60310834 feat(multi-site): auto-configure OpenLDAP replication on join/every run
Removes LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS as vars an operator has
to hand-set and keep in sync across every site. bootstrap/
site-ldap-register.js (new) asks sso-manager-node's new
GET /api/site/ldap-peers (spoke) or GET /directory-admin/
ldap-replication-config (master) for this node's assigned ServerID +
current peer list, persists it to /config/ldap-replication.env, and
restarts sso-manager only when the computed config actually changed
(OpenLDAP's static slapd.conf is only read at process start). Runs on
every setup.sh invocation -- both master (peer list grows as spokes
join) and spoke.

CFG_LDAP_MMR_MANUAL=true skips the automatic step entirely, for a
topology outside this theta-suite cluster the script can't derive on
its own -- without this escape hatch, an operator's hand-set
LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS would get silently overwritten
on the next run, since every fresh install starts as a master (the
automatic step always runs by default).

Bumps sso-manager-node to pick up the new endpoints + SiteSpoke.ldapServerId.
2026-08-10 23:02:01 -04:00
wmantly 20e9c1dfbe Merge pull request #214 from theta42/release-v2.6.0
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Has been skipped
release(v2.6.0): agent server_url/update fixes, real gateway count, dedupe groups
2026-08-10 19:40:16 -07:00
wmantly 98b2f9f389 release(v2.6.0): agent server_url/update fixes, real gateway count, dedupe groups 2026-08-10 22:39:16 -04:00
wmantly 90a1d19254 Merge pull request #213 from theta42/feat-site-slug-auto
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Has been skipped
feat(multi-site): auto-derive site slug; wire proxy/jump service integrations
2026-08-10 19:32:52 -07:00
wmantly 55d6f0b936 Merge remote-tracking branch 'origin/master' into feat-site-slug-auto 2026-08-10 22:30:17 -04:00
wmantly ba2e905155 Merge pull request #212 from theta42/fix-agent-server-url-and-stale-binary
fix(agent): server_url wiring + install from latest release instead of a stale committed binary
2026-08-10 19:29:40 -07:00
wmantly 455450db1f feat(multi-site): spoke.env.example + CFG_PUBLIC_DOMAIN
A dedicated spoke.env for the join-a-cluster vars (CFG_MASTER_DIRECTORY_URL/
_JOIN_KEY, CFG_SPOKE_NO_INBOUND/_PUBLIC_HOST, CFG_PUBLIC_DOMAIN), split out
of setup.env purely for clarity -- setup.env still has every option and
keeps working as a single file if that's preferred. setup.sh reads both
(setup.env first, spoke.env layered on top so its values win), same
first-run-only rule as setup.env already had.

Also adds CFG_PUBLIC_DOMAIN (documented in MULTI_SITE_SPEC.md §4 but never
actually wired into setup.sh): an inbound spoke/standalone site's own public
web domain, independent of CFG_DOMAIN (the shared LDAP identity namespace,
which must stay identical across every site). Unset behaves exactly as
before -- hostnames derive from CFG_DOMAIN like any standalone install.
2026-08-10 22:22:54 -04:00
wmantly 5ed83a2f59 feat(multi-site): auto-derive site slug; wire proxy/jump service integrations
Two real gaps found while fixing the Directory's Multi-Site modal:

1. SITE_SLUG was never set anywhere -- site_config.js's own fallback
   ("site-default") was all a fresh master could ever show, since
   nothing in setup.sh/docker-compose.yml passed it a value and
   bootstrap.js never generated one. Derived from CFG_SITE_NAME (same
   source jump-host's default exit node name already uses) with the
   same slugify rule bootstrap.js's own site Resource slug uses,
   formatted to match site_config.js's own "site-default" convention.
   Only a first-run default -- a real join/promote's persisted
   site.json value always wins.

2. PROXY_INTERNAL_URL and JUMP_INTERNAL_URL -- the env vars
   utils/proxy_client.js (no-inbound relay automation) and the new
   utils/jump_client.js (real gateway-mesh count on the modal) read to
   find each service -- were never actually set anywhere in
   docker-compose.yml. Both features existed in sso-manager-node's
   code but were completely unreachable in every real deployment,
   always hitting their "not configured" fallback. Wired both to the
   docker network hostnames.

Also documents how to mint + store the two integration API tokens
those features need (self-service tokens each app already has, not a
new credential type -- same reasoning as the relay automation).
2026-08-10 22:19:21 -04:00
wmantly ca812bed8e fix(agent): set server_url in agent.yml; install from latest release, not a stale committed binary
Two real bugs found on a live deployment:

1. setup.sh's theta-agent install step sed'd in join_key but never
   touched server_url, so /etc/theta42/agent.yml kept
   agent.yml.example's literal "https://sso.example.com" placeholder
   forever. Fixed for both first install and an already-installed
   agent.yml (self-heals server_url only, never touches
   join_key/auth_token, which may since have been rewritten by the
   agent itself with real issued credentials).

2. `theta-agent update` 404'd downloading
   https://sso.../resources/theta-agent/theta-agent-linux-amd64 --
   that route never existed server-side (only
   /resources/theta-agent/install.sh is static-served); self-update
   itself was already fixed upstream to pull from GitHub Releases, but
   setup.sh was still installing the binary committed in the
   theta-agent submodule checkout, which predated that fix and could
   therefore never self-update out of the bug. Switched setup.sh to
   download the current release binary from GitHub instead (matching
   theta-agent's own install.sh), and bumped the submodule to
   theta-agent's latest commit, which removes the stale committed
   binaries entirely -- this exact "stale committed binary" bug class
   has bitten this repo at least twice before (see theta-agent's
   CHANGELOG v1.5.0 entry).
2026-08-10 22:00:08 -04:00
11 changed files with 436 additions and 25 deletions
+5
View File
@@ -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
+69
View File
@@ -9,6 +9,75 @@ 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.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
+139
View File
@@ -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);
});
+11
View File
@@ -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
+2 -1
View File
@@ -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.0v2.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.0v2.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.0v2.3.0) | | WAN health check | **Shipped**`/api/site/ping`, live in the Master Site modal (v2.2.0v2.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. |
+7 -2
View File
@@ -38,8 +38,13 @@ 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`).
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.
+37 -7
View File
@@ -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 ────────────────────────────────────────────────
+110 -13
View File
@@ -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"
+54
View File
@@ -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