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.
This commit is contained in:
2026-08-10 22:22:54 -04:00
parent 5ed83a2f59
commit 455450db1f
6 changed files with 93 additions and 5 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
+1 -1
View File
@@ -256,7 +256,7 @@ 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. |
| 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. |
+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.
+10
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
+16 -2
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,8 +548,14 @@ 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 identity (site_config.js's `siteSlug`, shown on the Directory's
# Multi-Site modal) -- without this it's never set anywhere and every fresh # Multi-Site modal) -- without this it's never set anywhere and every fresh
+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