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.
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.
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).
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).
sso-manager-node/jump-host already had the relay-automation mechanism
(noInbound/meshIp/publicHost -> theta-proxy route via proxy_client.js,
GET /api/mesh/self on jump-host) but nothing in the actual operator
bring-up flow could ever reach it -- setup.sh, bootstrap/site-join.js,
and setup.env.example had zero wiring for it.
Add bootstrap/site-relay-register.js: reads this spoke's own role from
/config/site.json, logs into the local jump-host as its bootstrap
admin to discover its mesh IP, and registers it with the master. Mesh
peering itself stays a manual step (mint/paste a join token, same
pattern as the site join key), so this runs on every setup.sh
invocation via CFG_SPOKE_NO_INBOUND/CFG_SPOKE_PUBLIC_HOST and is a
no-op ("not meshed yet") until an operator has actually meshed the two
jump-hosts.
Also updates MULTI_SITE_SPEC.md's status table/TODO and the published
mesh.md docs page, which still described this as "designed but not
automated" after the API-level work had already shipped.
Extends the shipped CFG_MASTER_DIRECTORY_URL/JOIN_KEY join flow with the
selfUrl a spoke needs to register itself for live catalog replication
(theta-directory v2.4.0's POST /api/site/spokes) -- without this, every
spoke was permanently limited to the one-time join snapshot even after
the master gained the ability to push live updates.
setup.sh already computes CFG_SSO_HOST before this point in the script;
passes https://$CFG_SSO_HOST as bootstrap/site-join.js's third argument,
which forwards it as `selfUrl` in the POST /api/site/join body.
Multi-site join wiring (server + UI landed in theta-directory v2.3.0):
- bootstrap/site-join.js: runs inside the sso-manager container (same
self-contained rule as bootstrap.js); logs in as the bootstrap admin and calls
/api/site/join. Idempotent: an already-joined node reports 'already a spoke'.
- setup.sh step 5b: if setup.env sets CFG_MASTER_DIRECTORY_URL +
CFG_MASTER_DIRECTORY_JOIN_KEY, run the join after the bootstrap. Only honored
on first run (ensure_config reads setup.env once and ignores it once
./config/ exists), so an already-populated directory can never be merged.
- setup.env.example documents both vars.
- lint.yml also node --check's site-join.js.
- CFG_ORG default was 'SSO Manager', which became the browser tab title / app
name on fresh installs. Default is now 'Theta Directory' (existing deployments
keep their operator-owned ./config/sso-secrets.js name).
- Records theta-agent d937f8d (silent-install server_url + tray autostart +
self-update 404 fixes).
seed_node_conf() (added for sso-manager-node's node-scoped secrets
engine, DESIGN.md §5) had two bugs that made it fail on every call:
- Path had an extra "data/" segment (secret/data/nodes/<id>/<name>).
bao kv put takes the mount-relative path and inserts "data/" itself
for KV v2 -- same convention seed_app_conf already uses just above it
(secret/${vault_path}, not secret/data/${vault_path}). Fixed to
secret/nodes/<id>/<name>, which resolves under the hood to the
secret/data/nodes/<id>/* path api_agent_ops.js's node-scope check
expects.
- It piped "key=value\n" lines to `bao kv put path -`, but `-` there
means "read a JSON object from stdin", not KV lines -- failed with
"invalid key/value pair \"-\"" before ever reaching OpenBao. Fixed to
pass key=value pairs as ordinary CLI args.
Verified against a real OpenBao round-trip (write via the fixed
function, read back both via the CLI and the same HTTP path the SSO's
node-scope check uses).
docs/_config.yml: "theta-suite" -> "Theta Suite" in the Jekyll site title.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rolls up sso-manager-node v1.30.0, theta-agent v1.5.1, proxy v1.35.0.
The stack's own theta-agent could never connect. setup.sh generated a
random token locally and wrote it into agent.yml, but the SSO only
accepts credentials it issued, so it was rejected on every attempt and
the agent looped on "close 4001: Unauthorized" forever. It now writes a
join key the SSO minted; the agent exchanges it for its own token and the
SSO public key on first connect and rewrites its own config.
agent.yml was also left holding literal REPLACE_WITH_* placeholders once
the seds stopped matching the renamed fields, so a fresh install had no
credential at all. The file is chmod 600 now that it holds one.
A fresh install presented its own five containers as unmanaged
discoveries. The compose project name is passed to the Docker discovery
plugin, which recognises them and links each to the service it
implements. openbao and bao-renewer had no directory entries for their
containers to attach to; both are seeded as services now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rolls up sso-manager-node v1.29.0, theta-agent v1.4.0, proxy v1.34.0 and
jump-host v1.19.0.
Per-host SSO returned "400 redirect_uri is not registered for this
client". The bootstrap registered only the proxy's own management
callback, but per-host SSO calls back to
https://<protected-host>/__proxy_auth/callback -- a different URL per
proxied host, all against that one OAuth client. Now registers the
wildcard + apex patterns, and backfills them onto existing clients so
upgraded stacks are fixed too.
theta-proxy and theta-jump were seeded as hosts and then left childless
while their services hung off the stack host. Services now parent to the
host that runs them; reparent() corrects existing installs, but only when
the current parent is the one the old code set.
The proxy gets a read-only SSO API token (minted before the OpenBao
snapshot so the running proxy receives it) backing the per-host SSO group
autocomplete, and the sso-broker policy grants secret/agent/* for the
SSO's persistent theta-agent signing key.
BREAKING: theta-agents must be re-enrolled, and ./setup.sh must be re-run
for the new OpenBao grant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- setup.sh: ldap_host defaults to localhost (the public sso.<domain> can't reach
the 389/636 LDAP ports through NAT); overridable via CFG_LDAPS_HOST
- ldap.vars access groups + ldap-client sssd filter now reference the SSO group
model (site_<loc>_hosts_access, site_<loc>_host_<host>_access, god_admin)
- GROUPS.md §5/§8 updated to the corrected naming
- gitlink: ldap-client ebaac18 (v1.24.0)
- setup.sh: ldap.vars generation read CFG_* first-run vars (unset on re-run);
now reads real values from sso-secrets.js, so LDAP enrollment works on re-runs
- generated ldap_access_groups now references god_admin (app_super_admin gone)
- gitlink: sso-manager-node 8db00f0 (v1.26.1)
cp into a running executable fails with 'Text file busy' on a re-install.
Stop the service before copying the prebuilt binary.
Co-Authored-By: Claude <noreply@anthropic.com>
- theta-svc token role (periodic 768h): SSO/PROXY/JUMP_VAULT_TOKEN now minted
through it; ensure_token renews periodic tokens on every setup.sh re-run and
detects/revokes/re-mints valid-but-non-periodic tokens from older installs.
- bao-renewer sidecar (docker-compose): renews the three service tokens every
12h while the stack runs.
- sso-app token role (periodic 768h) + sso-broker policy grants for
auth/token/create/sso-app and renew/revoke/lookup-accessor.
- docs/secrets.md rewritten around the new lifecycle.
- Bump sso-manager-node gitlink to v1.23.0 (real vault-403 fix + app-token
lifecycle).
Co-Authored-By: Claude <noreply@anthropic.com>
setup.sh's 'full control' edit set service_control: true, but that field is a
[]string allowlist, so theta-agent failed YAML decode and crash-looped. Remove
the invalid edit; leave the operator's allowlist (or [] default = deny all).
Co-Authored-By: Claude <noreply@anthropic.com>
On a re-run where OpenBao is already unsealed, the unseal block is skipped and
UNSEAL_KEY is never set; line 778 then referenced it under set -u and aborted
with 'UNSEAL_KEY: unbound variable'. Guard with ${UNSEAL_KEY:-} so the
VAULT_UNSEAL_KEY upsert is simply skipped when there's no key this run.
Co-Authored-By: Claude <noreply@anthropic.com>
- Add --reset-openbao: full clean OpenBao reset (re-init store, flush the
Redis vault-token cache) to clear stale policies/tokens causing recurring
vault 403s.
- Fix theta-agent install: copy the prebuilt theta-agent-linux-amd64 from the
submodule instead of a broken go build; write config to /etc/theta42/agent.yml
(the path the agent reads), not /etc/theta/agent.yml.
- Bump sso-manager-node gitlink to v1.21.0 (shared secrets + durable vault 403 fix).
Co-Authored-By: Claude <noreply@anthropic.com>
- bump sso-manager-node submodule gitlink v1.17.1 -> v1.17.2
(post-deploy fixes: auto-slug plugins, schedule dropdown, /profile
rendering, plugin-edit persistence, nmap in image, SMS/TOS on /conf,
sso-side /vault policy grants)
- setup.sh: add sso-admin list grant on secret/metadata (KV mount root)
so the /vault secrets list no longer 403s for admins
- setup.sh: ensure_policy now always (re)writes the policy so policy
edits apply on a re-run instead of stranding the old HCL
- CHANGELOG embeds the full sso v1.17.2 changelog
Co-Authored-By: Claude <noreply@anthropic.com>
Prerequisite for the SSO Manager plugin system (shipped in sso-manager-node
v1.17.0). Adds secret/data/plugins/* (CRUD+list) + secret/metadata/plugins/*
(list/read/delete) to the sso-broker policy HCL so the SSO can store per-instance
plugin secrets in OpenBao instead of sso-secrets.js. ensure_policy is idempotent,
so re-running ./setup.sh grants the existing SSO_VAULT_TOKEN live.
Docs: secrets.md (Plugin secrets section + policy row), architecture.md.
Co-Authored-By: Claude <noreply@anthropic.com>
Rename the project to theta-suite (it is now an integrated suite of four
apps around a shared OpenBao secrets store, not a two-project env).
- theta-env -> theta-suite across the superproject: _config.yml (title +
baseurl /theta-suite + repo URLs), README, setup.sh (incl. the
THETA_SUITE_REEXECED self-update sentinel), docker-compose.yml,
bootstrap.js, lint.yml, config.example/*, docs/robots.txt, all docs,
this changelog.
- architecture.md rewritten: real 4-service + ldap-client topology, OpenBao
secrets section, OpenBao-aware config flow; removed "two containers" /
"three repos" / LDAP-"legacy" framing.
- index.md: integrated-suite framing + secrets/OpenBao + ldap-client.
- standalone.md + README: standalone reframed as advanced opt-in.
- sso-manager-node submodule -> v1.16.1 (401 fix on /conf and /vault).
Co-authored-by: Claude <noreply@anthropic.com>
Two fresh-install fixes and promote the SSH jump host from opt-in to core.
setup.sh: fix silent abort after "Minting per-app OpenBao tokens". env_get's
grep|cut pipeline returns non-zero under set -euo pipefail when .env exists
(created by the root VAULT_TOKEN env_upsert) but an app-token key is absent
(the normal first-run state); the unguarded existing assignment from env_get
then tripped set -e and killed the script before minting any token. env_get
now always returns 0 (|| true). Reproduced + verified under the exact condition.
jump host is no longer optional:
- docker-compose.yml: drop profiles jump-host from the jump-host service
(always started); rename the opt-in test fixture profile jump-host to ldap-test.
- setup.sh: SUBMODULES always includes jump-host; build/start/register/summary
no longer guarded by JUMP_ENABLED; drop the COMPOSE_PROFILES export.
- bootstrap.js: jump provisioning + directory record run unconditionally.
- setup.env.example/docs: drop optional/CFG_JUMP_HOST_ENABLED wording.
Co-authored-by: Claude <noreply@anthropic.com>
- Bump jump-host, ldap-client, proxy, sso-manager-node submodules to their
new tags (cross-app app_super_admin group, jump-host's app_jump_admin,
ldap-client's SSH host access for super admins).
- setup.sh: new env_upsert helper persists SSO_GIT_COMMIT/PROXY_GIT_COMMIT/
JUMP_GIT_COMMIT into ./.env (docker compose's auto-loaded env file) so an
ad-hoc single-service rebuild outside a full setup.sh run still bakes the
right commit hash instead of "unknown".
sso-manager's Directory data had no persistent volume at all: @simpleworkjs/orm
falls back to './config/inventory.sqlite' (relative to /app) when conf.orm isn't
set, which lives in the container's ephemeral layer -- every container recreate
silently wiped every site/host/service/oauth-client resource and their
relationships. setup.sh's generated sso-secrets.js now sets
orm.storage=/data/inventory.sqlite, co-located with the already-persisted
sso-data volume (same one Redis uses). Verified live: migrated the running
stack's existing Directory data to /data, force-recreated the container, and
confirmed all 8 resources survived.
Also bumps sso-manager-node to v1.8.0 (resource modal standardization: tabs,
footer with created/updated-by metadata, linkable /directory/:slug URLs, a new
Children tab, and site-slug-prefixed auto-created LDAP groups).
CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY in setup.env (all
optional, unset by default) get wired into every service's docker build
(npm/apt) and running container (SMTP, ACME/Let's Encrypt, DNS provider
calls, the jump-host directory API client) as HTTP_PROXY/HTTPS_PROXY/
NO_PROXY. Useful for isolated/offline/corporate-network test hosts that
only reach the internet through an upstream proxy — distinct from the
theta42 "proxy" app itself. CFG_NO_PROXY defaults to the stack's own
internal service names so container-to-container traffic never routes
through the proxy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds jump-host as a third, opt-in submodule, wired behind
CFG_JUMP_HOST_ENABLED (default off — existing installs unaffected):
- .gitmodules + jump-host submodule pinned to v1.0.0
- setup.sh: resolves the enable flag early, adds jump-host to the
submodule tag-update loop and activates the `jump-host` compose
profile when enabled; builds/starts the service after the proxy,
waits for its /health, and registers its web UI as a proxy Host;
passes CFG_JUMP_HOST_ENABLED/CFG_JUMP_HOST to the bootstrap
- docker-compose.yml: jump-host service with profiles:["jump-host"],
depends_on sso-manager healthy, ports 2222 (SSH) + 3002 (web),
./config:ro + jump-data volume
- bootstrap.js: when enabled, mints a directory API token and writes
./config/jump-secrets.js (binds as cn=admin so it can write the
sshPublicKey attribute for key injection), and seeds a directory
service entry for the jump host. Warn-only, idempotent.
- setup.env.example: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CFG_SITE_NAME in setup.env (below CFG_DOMAIN, default "local") names
the directory site; slug site_<name> matches ldap-client's parentSlug
convention so joined Linux hosts land under the same site. Wired
through sso-secrets.js stack.siteName.
- setup.sh collects host facts ON THE HOST (hostname, IP, default-route
MAC, OS pretty-name, kernel — same collection as ldap-client/index.sh)
and passes them into the bootstrap exec env; the stack host is now
registered as host_<hostname> with that metadata (subType linux).
- Services carry their internal port and git repo in metadata
(sso-manager 3001, proxy 3000, openldap 389/ext 636, openresty 443),
using the metadata keys the directory UI natively displays.
- ensure() now adopts resources from the earlier seed layout (alt slugs
'stack-host' / domain-slug site) and back-fills missing seed metadata
via a metadata-only PUT — operator-set values are never overwritten.
Verified against a live app: old-layout resources are adopted and
back-filled (no duplicates), fresh seed creates the full graph, and a
second pass changes nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Directory page started empty even though setup.sh knows exactly what
it deployed. The bootstrap now seeds (via /api/directory-admin, as the
logged-in admin): a site from the configured domain, a "Stack host", the
SSO Manager + Proxy services with their public URLs in metadata, and
links the proxy's auto-registered OAuth client under its service.
Idempotent: resources whose slug already exists are operator-owned and
never touched. A seed failure only warns — never fails a bring-up (e.g.
against an older sso-manager image without /api/directory-admin).
Verified against a live app (sso-manager-node test stack): first pass
creates site/host/2 services + oauth edge; second pass changes nothing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>