diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf3101..1a784f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,41 @@ orchestration code; see each submodule's own `CHANGELOG.md` [sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)) for what changed inside the apps it composes. +## [v1.29.0] - 2026-08-01 + +Two fixes for a fresh `./setup.sh` install, plus the SSH jump host promoted +from an opt-in component to a core part of the stack. + +### Fixed (theta-env orchestration) +- **`setup.sh`** — fresh installs aborted silently right after `Minting + per-app OpenBao tokens`. The `env_get` helper's `grep | cut` pipeline returns + non-zero under `set -euo pipefail` when `.env` exists (it's created earlier + by the root-`VAULT_TOKEN` `env_upsert`) but a given app-token key is absent — + the normal first-run state. The unguarded `existing="$(env_get ...)"` then + tripped `set -e` and killed the script before any token was minted. `env_get` + now always returns 0 (`|| true`), so "key absent" resolves to empty and the + run continues through token minting, the SSO/proxy bring-up, and the jump + host. Reproduced + verified the fix under the exact fresh-install condition. +- **`setup.sh`** — `JUMP_VAULT_TOKEN` is now always minted (jump host is core; + the mint was already unconditional, this just documents it). + +### Changed (theta-env orchestration) +- **jump host is no longer optional** — it is built + started on every run, + with no `CFG_JUMP_HOST_ENABLED` flag. + - `docker-compose.yml`: removed `profiles: ["jump-host"]` from the + `jump-host` service so `docker compose up` includes it unconditionally. + The test-only `ldap-test-host` downstream fixture keeps an opt-in profile, + renamed `jump-host` → `ldap-test` (`docker compose --profile ldap-test up`). + - `setup.sh`: `SUBMODULES` always includes `jump-host`; the build/start + + host-register + summary lines for the jump host are no longer wrapped in a + `JUMP_ENABLED` guard; the `COMPOSE_PROFILES` export is gone. + - `bootstrap/bootstrap.js`: jump-host provisioning (mint API token + write + `jump-secrets.js` + mirror into OpenBao) and its directory service record + now run unconditionally — no `CFG_JUMP_HOST_ENABLED` gate. + - `setup.env.example` / `docs/index.md` / `docs/quickstart.md`: dropped the + "optional / enable with `CFG_JUMP_HOST_ENABLED=true`" wording; the + `CFG_JUMP_HOST` hostname override + `JUMP_SSH_PORT` remain. + ## [v1.28.0] - 2026-08-01 OpenBao becomes the central secrets store for the whole stack. Every app now diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index cbbc8f0..c46b827 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -464,9 +464,9 @@ async function seedDirectory(token, clientId, jumpClientId) { subType: 'openresty', }); - // Optional SSH jump host service. + // SSH jump host service (core component — always registered). let jumpSvc = null; - if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) { + { const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, { address: jumpHost ? `https://${jumpHost}` : '', @@ -523,16 +523,16 @@ function writeProxyCreds(id, secret) { } } -// ── 6. Optional: provision the SSH jump host ──────────────────────────────── -// When CFG_JUMP_HOST_ENABLED=true, the jump host needs: a directory API token -// (to resolve which hosts a user may reach), an LDAP bind account that can -// WRITE the sshPublicKey attribute (it injects its own key on first use), and -// a config file it reads. We write /config/jump-secrets.js deriving LDAP/site -// from sso-secrets.js + a freshly minted API token. The bundled jump host -// binds as cn=admin (already able to write sshPublicKey) — hardened bare-metal -// deployments should use a scoped account + attribute ACL instead (see the -// jump-host README). Idempotent: skips if the file already has a real token. -const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || ''); +// ── 6. Provision the SSH jump host ───────────────────────────────────────── +// The jump host is a core component (always provisioned). It needs: a directory +// API token (to resolve which hosts a user may reach), an LDAP bind account +// that can WRITE the sshPublicKey attribute (it injects its own key on first +// use), and a config file it reads. We write /config/jump-secrets.js deriving +// LDAP/site from sso-secrets.js + a freshly minted API token. The bundled jump +// host binds as cn=admin (already able to write sshPublicKey) — hardened +// bare-metal deployments should use a scoped account + attribute ACL instead +// (see the jump-host README). Idempotent: skips if the file already has a real +// token. const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); const JUMP_SECRETS = '/config/jump-secrets.js'; const JUMP_TOKEN_NAME = 'theta-jump-host'; @@ -716,23 +716,22 @@ async function provisionJumpHost(token) { // generated OAuth creds). Warn-only. await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js')); - // Provision the jump host (mint token + write config) when enabled. - // Warn-only — never fail the whole bring-up over the optional service. + // Provision the jump host (mint token + write config). Warn-only — never + // fail the whole bring-up over it, but it's a core component so always + // attempted (no longer gated by CFG_JUMP_HOST_ENABLED). let jumpClientId = null; - if (JUMP_ENABLED) { - try { - jumpClientId = await provisionJumpHost(token); - out('JUMP_HOST_CONFIGURED', '1'); - // Mirror jump-secrets.js (just written by provisionJumpHost) - // into OpenBao so the jump host loads it from there at boot via - // @simpleworkjs/bao-conf. setup.sh's seed may have put a - // placeholder/stale version here; this replaces it with the - // complete file (LDAP bind, minted API token, OAuth client). - // Warn-only. - await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS)); - } catch (e) { - log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`); - } + try { + jumpClientId = await provisionJumpHost(token); + out('JUMP_HOST_CONFIGURED', '1'); + // Mirror jump-secrets.js (just written by provisionJumpHost) into + // OpenBao so the jump host loads it from there at boot via + // @simpleworkjs/bao-conf. setup.sh's seed may have put a + // placeholder/stale version here; this replaces it with the + // complete file (LDAP bind, minted API token, OAuth client). + // Warn-only. + await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS)); + } catch (e) { + log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`); } // Seed the directory (site/host/services + OAuth client link). Never diff --git a/docker-compose.yml b/docker-compose.yml index 9130330..7bd3bb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -170,12 +170,10 @@ services: retries: 3 start_period: 30s - # Optional SSH jump host. Only started when the `jump-host` compose profile - # is active — setup.sh exports COMPOSE_PROFILES=jump-host when - # CFG_JUMP_HOST_ENABLED=true. Authenticates users against the SSO's OpenLDAP, - # resolves reachable hosts from the directory API, and bridges SSH through. + # SSH jump host — a core component, always built + started alongside the + # SSO and proxy. Authenticates users against the SSO's OpenLDAP, resolves + # reachable hosts from the directory API, and bridges SSH through. jump-host: - profiles: ["jump-host"] build: context: ./jump-host dockerfile: Dockerfile @@ -220,11 +218,11 @@ services: # a container with a manually-dropped public key in authorized_keys never # exercises the LDAP-key-serving path a real production host does. Built # from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars - # for setup notes. Same jump-host profile, so - # `docker compose --profile jump-host up` brings up jump-host and a host it - # can actually reach together. + # for setup notes. Opt-in test fixture: bring it up explicitly with + # `docker compose --profile ldap-test up` (jump-host itself now starts + # unconditionally, so this only adds a downstream host for it to reach). ldap-test-host: - profiles: ["jump-host"] + profiles: ["ldap-test"] build: context: ./ldap-client dockerfile: Dockerfile diff --git a/docs/index.md b/docs/index.md index b62d702..b048113 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an OIDC-protected reverse proxy that can also look users up directly in LDAP) — and automates the fiddly part: registering the proxy as an OIDC client of the SSO and pointing it at the right LDAP directory, with hostnames and secrets -generated from one `setup.env`. An optional third component, the +generated from one `setup.env`. A third component, the [Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH access to your machines through one public entry point. @@ -46,9 +46,9 @@ snapshots state before every rebuild. - **Proxy** — add the hosts you want to protect with OIDC login. - **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and LDAP-native apps authenticate against the same directory. -- **SSH Jump Host** *(optional)* — `ssh uid_-_host@jump.` (WinSCP-friendly) +- **SSH Jump Host** — `ssh uid_-_host@jump.` (WinSCP-friendly) or an interactive picker; access is driven by directory group membership, with - a web UI for audit + metrics. Enable with `CFG_JUMP_HOST_ENABLED=true`. + a web UI for audit + metrics. - **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations. @@ -74,5 +74,5 @@ architecture, and running each project standalone, see the provider + LDAP directory this stack runs. - **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this stack runs in front of it. -- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump - host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`). +- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump + host this stack brings up. diff --git a/docs/quickstart.md b/docs/quickstart.md index fb6997d..01c2fb7 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -60,8 +60,7 @@ setups `CFG_DOMAIN` is the only value you set: | `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` | | `CFG_ADMIN_EMAIL` | `admin@` | optional | | `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN | -| `CFG_JUMP_HOST_ENABLED` | `true` | optional: bring up the [SSH jump host](https://theta42.github.io/jump-host/) (default off) | -| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.` | +| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.` (the [SSH jump host](https://theta42.github.io/jump-host/) is installed + started by default) | | `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) | `setup.env` is used **only on the first run** to generate `./config/`; after diff --git a/setup.env.example b/setup.env.example index 5b32ca7..fba575d 100644 --- a/setup.env.example +++ b/setup.env.example @@ -33,15 +33,14 @@ CFG_DOMAIN=example.com #CFG_SSO_HOST=sso.example.com #CFG_PROXY_HOST=proxy.example.com -# ── Optional SSH jump host ─────────────────────────────────────────────────── -# Enable the theta42/jump-host component: a public SSH jump host that -# authenticates users against the directory and bridges them to downstream -# hosts (ssh uid_-_target@jump, or an interactive picker). Off by default. -# When true, setup.sh clones/builds the jump-host submodule, the bootstrap -# mints its directory API token + writes ./config/jump-secrets.js, and it's -# registered in the proxy + directory. See jump-host's README for the LDAP -# write-ACL note (the bundled deployment binds as cn=admin). -#CFG_JUMP_HOST_ENABLED=false +# ── SSH jump host (always installed) ───────────────────────────────────────── +# The theta42/jump-host component is installed and started by default — a +# public SSH jump host that authenticates users against the directory and +# bridges them to downstream hosts (ssh uid_-_target@jump, or an interactive +# picker). setup.sh clones/builds the jump-host submodule, the bootstrap mints +# its directory API token + writes ./config/jump-secrets.js, and it's registered +# in the proxy + directory. See jump-host's README for the LDAP write-ACL note +# (the bundled deployment binds as cn=admin). #CFG_JUMP_HOST=jump.example.com # defaults to jump. #JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default) diff --git a/setup.sh b/setup.sh index 94ea154..499af09 100755 --- a/setup.sh +++ b/setup.sh @@ -169,18 +169,14 @@ then fi fi -# ── Optional jump host: resolve the enable flag early ───────────────────────── -# CFG_JUMP_HOST_ENABLED gates the optional SSH jump host (a third submodule). -# Read it from the environment or ./setup.env now (before the submodule loop -# and the compose steps) so every run knows whether to build/start it. The -# authoritative CFG_* for secrets are still resolved in ensure_config; this is -# only the on/off switch + its hostname. +# ── Jump host hostname (always installed) ───────────────────────────────────── +# The SSH jump host is a core component — always built + started (no longer +# gated by CFG_JUMP_HOST_ENABLED). Read its optional hostname override from +# ./setup.env now (before the submodule loop and the compose steps) so the +# 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 -JUMP_ENABLED=0 -case "${CFG_JUMP_HOST_ENABLED:-}" in 1|true|TRUE|yes|YES) JUMP_ENABLED=1 ;; esac -export CFG_JUMP_HOST_ENABLED CFG_JUMP_HOST -# When enabled, activate the compose profile so `up`/`ps` include the service. -if [[ "$JUMP_ENABLED" == "1" ]]; then export COMPOSE_PROFILES="jump-host"; fi +export CFG_JUMP_HOST # ── Optional outbound HTTP(S) proxy for docker build + the running containers ─ # CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the @@ -213,9 +209,8 @@ if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then die "git submodule update --init failed. Run manually: git submodule update --init --recursive" fi - # jump-host is optional: only track/build it when enabled. - SUBMODULES=(sso-manager-node proxy) - [[ "$JUMP_ENABLED" == "1" ]] && SUBMODULES+=(jump-host) + # jump-host is a core component — always tracked + built. + SUBMODULES=(sso-manager-node proxy jump-host) info "Updating submodules to their latest release tag (${SUBMODULES[*]})..." for sm in "${SUBMODULES[@]}"; do [[ -d "$sm" ]] || continue @@ -730,7 +725,15 @@ ensure_policy() { env_get() { local key="$1" file=./.env [[ -f "$file" ]] || return 0 - grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- + # `|| true` is load-bearing: under `set -euo pipefail`, a no-match `grep` + # exits 1 and (pipefail) makes the whole pipeline return 1. Callers do + # `existing="$(env_get ...)"` as a bare assignment — a non-zero return there + # trips `set -e` and silently kills the whole script (this is exactly what + # aborted a fresh install right after "Minting per-app OpenBao tokens": the + # root VAULT_TOKEN env_upsert had already created .env, but the app-token + # keys were absent, so the first env_get returned 1). "Key absent" is the + # normal path here, so always return 0 with empty output. + grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true } # Mint an orphan, renewable token for `policy` and persist it to .env as `key`, @@ -904,7 +907,6 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \ -e STACK_HOST_MAC="$STACK_HOST_MAC" \ -e STACK_HOST_OS="$STACK_HOST_OS" \ -e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \ - -e CFG_JUMP_HOST_ENABLED="${CFG_JUMP_HOST_ENABLED:-}" \ -e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \ -e VAULT_ADDR=http://openbao:8200 \ -e VAULT_TOKEN="$VAULT_TOKEN" \ @@ -984,35 +986,34 @@ NODEEOF ) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}" echo "$HOSTS_OUT" | sed 's/^/[setup] /' -# ── 7b. Optional: build + start the SSH jump host ───────────────────────────── -# Enabled by CFG_JUMP_HOST_ENABLED. The bootstrap (step 5) already wrote -# ./config/jump-secrets.js (minted API token + LDAP admin bind). Build/start the -# service (compose profile 'jump-host' is active), wait for its web /health, and -# register its web UI hostname as a proxy Host so https:// routes. -if [[ "$JUMP_ENABLED" == "1" ]]; then - JUMP_HOST="${CFG_JUMP_HOST:-jump.${SSO_HOST#sso.}}" - JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)" - export JUMP_GIT_COMMIT - env_upsert JUMP_GIT_COMMIT "$JUMP_GIT_COMMIT" - # Seed jump-host/conf from the file bootstrap just wrote (it mints the API - # token + OAuth client into /config/jump-secrets.js at step 5). bootstrap - # also writes this to OpenBao directly, so this is a fallback for when - # bootstrap's jump provisioning warned-but-continued. - seed_app_conf jump-host/conf /config/jump-secrets.js - info "Building + starting jump-host (optional; enabled via CFG_JUMP_HOST_ENABLED)..." - "${COMPOSE[@]}" up -d --build jump-host +# ── 7b. Build + start the SSH jump host ────────────────────────────────────── +# The jump host is a core component (no longer optional). The bootstrap (step 5) +# already wrote ./config/jump-secrets.js (minted API token + LDAP admin bind) and +# mirrored it into OpenBao. Build/start the service, wait for its web /health, +# and register its web UI hostname as a proxy Host so https:// routes. +JUMP_HOST="${CFG_JUMP_HOST:-jump.${SSO_HOST#sso.}}" +JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)" +export JUMP_GIT_COMMIT +env_upsert JUMP_GIT_COMMIT "$JUMP_GIT_COMMIT" +# Seed jump-host/conf from the file bootstrap just wrote (it mints the API +# token + OAuth client into /config/jump-secrets.js at step 5). bootstrap also +# writes this to OpenBao directly, so this is a fallback for when bootstrap's +# jump provisioning warned-but-continued. +seed_app_conf jump-host/conf /config/jump-secrets.js +info "Building + starting jump-host..." +"${COMPOSE[@]}" up -d --build jump-host - info "Waiting for jump-host to be healthy..." - for i in $(seq 1 60); do - if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then - info "jump-host is healthy."; break - fi - if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi - sleep 2 - done +info "Waiting for jump-host to be healthy..." +for i in $(seq 1 60); do + if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then + info "jump-host is healthy."; break + fi + if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi + sleep 2 +done - info "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..." - JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node < { try { @@ -1027,8 +1028,7 @@ const {Host} = require('/app/models').models; })(); NODEEOF ) - echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /' -fi +echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /' # ── 8. Summary ─────────────────────────────────────────────────────────────── echo @@ -1038,11 +1038,9 @@ echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}" echo " Proxy mgmt UI: https://${PROXY_HOST}" echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}" -if [[ "$JUMP_ENABLED" == "1" ]]; then echo " Jump host (SSH): ssh -p ${JUMP_SSH_PORT:-2222} @${JUMP_HOST:-jump.${SSO_HOST#sso.}} (TUI picker)" echo " ssh -p ${JUMP_SSH_PORT:-2222} _-_@${JUMP_HOST:-jump.${SSO_HOST#sso.}}" echo " Jump host (web): https://${JUMP_HOST:-jump.${SSO_HOST#sso.}} (audit + metrics)" -fi echo echo " First admin login credentials are in ./config/sso-secrets.js:" echo " user: ${ADMIN_UID}"