Compare commits

..

8 Commits

Author SHA1 Message Date
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
10 changed files with 215 additions and 18 deletions
+5
View File
@@ -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
+44
View File
@@ -9,6 +9,50 @@ orchestration code; see each submodule's own `CHANGELOG.md`
[jump-host](https://github.com/theta42/jump-host/blob/master/CHANGELOG.md))
for what changed inside the apps it composes.
## [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
Rolls up **sso-manager-node v2.5.0** and **jump-host v2.1.1** — closes the
+11
View File
@@ -71,8 +71,19 @@ services:
# setup.sh (policy sso-broker) — NOT the root token.
- NODE_ENV=production
- 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_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_TOKEN=${SSO_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
+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) |
| 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) |
| `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. |
+7 -2
View File
@@ -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.
+24
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
# 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
@@ -78,6 +88,20 @@ CFG_DOMAIN=example.com
#CFG_SPOKE_NO_INBOUND=true
#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 ──────────────────────────────────────────
# For an isolated/offline/corporate-network test host that only reaches the
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
+67 -13
View File
@@ -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,9 +548,25 @@ 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
# 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_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
@@ -1305,16 +1329,28 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
info "Setting up theta-agent on the host..."
(
cd theta-agent || exit 0
# Install the prebuilt binary that ships in the theta-agent submodule (the
# repo's own install.sh uses the same release binary). We do NOT build from
# source here: 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.
if [[ ! -f "theta-agent-linux-amd64" ]]; then
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
# Download the current release binary from GitHub rather than trusting a
# binary committed in the submodule checkout. A committed binary drifts:
# theta-agent's own `theta-agent update` moved to pulling from GitHub
# Releases (DESIGN-WINDOWS.md §9, "nothing binary lives in the repos")
# once, but this script kept installing the stale binary that shipped
# with an old submodule pin, which still pointed `update` at a dead
# 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
info " Installing prebuilt theta-agent binary..."
if [[ -x "theta-agent-linux-amd64" ]]; then
chmod +x "$AGENT_BIN_TMP"
info " Installing theta-agent binary..."
if true; then
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
sudo mkdir -p /etc/theta42
if [[ ! -f /etc/theta42/agent.yml ]]; then
@@ -1345,7 +1381,12 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
else
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
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 >/dev/null 2>&1 || sudo groupadd -r theta 2>/dev/null || true
SECRETS_GRP="root"
@@ -1353,12 +1394,25 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
sudo chown -R "root:$SECRETS_GRP" /etc/theta42 2>/dev/null || true
sudo chmod 750 /etc/theta42
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
# Stop a running agent before overwriting its binary (cp into a
# running executable fails with "Text file busy" on a re-install).
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
rm -f "$AGENT_BIN_TMP"
# Install desktop tray companion if available
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