From 455450db1fb3a5f3ee9cecdef68e8d9dc063829d Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 22:22:54 -0400 Subject: [PATCH] feat(multi-site): spoke.env.example + CFG_PUBLIC_DOMAIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 5 ++++ docs/MULTI_SITE_SPEC.md | 2 +- docs/sso/multi-site.md | 9 +++++-- setup.env.example | 10 ++++++++ setup.sh | 18 ++++++++++++-- spoke.env.example | 54 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 spoke.env.example diff --git a/.gitignore b/.gitignore index 8d541e1..ee19479 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ proxy.env # per-deployment and is not committed. 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) *.rdb *.ldif diff --git a/docs/MULTI_SITE_SPEC.md b/docs/MULTI_SITE_SPEC.md index 4033524..6b3f940 100644 --- a/docs/MULTI_SITE_SPEC.md +++ b/docs/MULTI_SITE_SPEC.md @@ -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.0–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) | -| `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`. | | 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. | diff --git a/docs/sso/multi-site.md b/docs/sso/multi-site.md index 5492ac3..3b97b71 100644 --- a/docs/sso/multi-site.md +++ b/docs/sso/multi-site.md @@ -38,8 +38,13 @@ between the two. admin, no enrolled agents), either: - Paste the master's URL and the join key into the Master Site modal's **Join an Existing Site** form, or - - Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` in - `setup.env` before the first `./setup.sh` run. + - Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` before + 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, agent-signing key) and adopts it, then registers its own reachable URL with the master so it can receive live updates going forward. diff --git a/setup.env.example b/setup.env.example index a9bf17b..0532191 100644 --- a/setup.env.example +++ b/setup.env.example @@ -61,9 +61,19 @@ CFG_DOMAIN=example.com # 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 # 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_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 # 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 diff --git a/setup.sh b/setup.sh index 0a94efc..3d22bce 100755 --- a/setup.sh +++ b/setup.sh @@ -200,6 +200,10 @@ fi # later steps can use it. The authoritative CFG_* for secrets are still # resolved in ensure_config; this is only the hostname override. [[ -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 CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}" export CFG_CREATE_ALL_HTTP @@ -467,6 +471,10 @@ BAOEOF info "Reading domain/hosts from ./setup.env ..." parse_kv_file ./setup.env 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 # them, so the .env migration's `${LDAP_X:-$CFG_X}` defaults below don't trip @@ -540,8 +548,14 @@ BAOEOF [[ -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" CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}" - CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}" - CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}" + # CFG_PUBLIC_DOMAIN (MULTI_SITE_SPEC.md §4): an inbound spoke's own public + # 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}" # 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 diff --git a/spoke.env.example b/spoke.env.example new file mode 100644 index 0000000..d375ff0 --- /dev/null +++ b/spoke.env.example @@ -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