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:
@@ -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
|
||||||
|
|||||||
@@ -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) |
|
| Join key issuance + one-time directory adoption | **Shipped** — `/api/site/join-keys`, `/api/site/export`, `/api/site/join`, fresh-install-gated (v2.2.0–v2.3.0) |
|
||||||
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
||||||
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
||||||
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0) |
|
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0). Also readable from a dedicated `spoke.env` (`spoke.env.example`, layered on top of `setup.env`) for operators who want join-a-cluster config kept separate from the rest of first-run setup. |
|
||||||
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
||||||
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
||||||
| 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. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user