diff --git a/.gitmodules b/.gitmodules index 686b34a..fa9fa7a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,7 @@ [submodule "proxy"] path = proxy url = https://github.com/theta42/proxy.git +[submodule "jump-host"] + path = jump-host + url = https://github.com/theta42/jump-host.git + branch = master diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3ee52..ff000c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ for what changed inside the apps it composes. ## [Unreleased] +### Added +- **Optional SSH jump host** (theta42/jump-host) as a third, opt-in submodule. Enable with `CFG_JUMP_HOST_ENABLED=true` in `setup.env`: setup.sh clones/tag-tracks the submodule and builds it behind the `jump-host` compose profile, the bootstrap mints a directory API token and writes `./config/jump-secrets.js` (LDAP admin bind so it can inject users' `sshPublicKey`), the jump host is registered as a proxy Host (its web UI) and seeded as a directory service. Users then `ssh uid_-_host@jump.` (WinSCP-friendly) or `ssh uid@jump.` for a TUI host picker; the web UI on :3002 shows audit + metrics. Off by default — existing installs are unaffected. + ## [1.3.4] - 2026-07-23 ### Bumped diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index 878ef31..ee88616 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -418,6 +418,17 @@ async function seedDirectory(token, clientId) { subType: 'openresty', }); + // Optional SSH jump host service. + if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) { + const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); + await ensure('service', 'SSH Jump Host', 'jump-host', host.id, { + address: jumpHost ? `https://${jumpHost}` : '', + port: 3002, + gitRepo: 'https://github.com/theta42/jump-host', + subType: 'ssh', + }); + } + // Link the proxy's OAuth client (Resource-backed since sso-manager 1.3.0) // under its service, if it appears in the directory and isn't linked yet. if (clientId) { @@ -463,6 +474,87 @@ 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 || ''); +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'; + +async function mintApiToken(token, name) { + const res = await fetch(`${SSO_INTERNAL}/api/api-token`, { + method: 'POST', + headers: { 'auth-token': token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description: 'theta-env jump host (auto-registered)' }), + }); + if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`); + const data = await res.json(); + const raw = data.token || (data.results && data.results.token) || data.raw_token; + if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`); + return raw; +} + +function jumpFileHasToken() { + try { + const src = fs.readFileSync(JUMP_SECRETS, 'utf8'); + return /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src); + } catch (_) { return false; } +} + +function writeJumpSecrets(apiToken) { + const siteName = (sso.stack && sso.stack.siteName) || 'local'; + const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST; + const body = `'use strict'; +// Generated by theta-env bootstrap. The jump host reads this via +// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the +// sshPublicKey attribute (key injection); for a hardened deployment use a +// scoped account with an sshPublicKey write-ACL instead (see jump-host README). +module.exports = { +\tname: ${JSON.stringify(sso.name || 'SSO Manager')}, +\tldap: { +\t\turl: 'ldap://sso-manager:389', +\t\tbindDN: ${JSON.stringify(BIND_DN)}, +\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)}, +\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)}, +\t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)}, +\t\ttlsOptions: { rejectUnauthorized: false }, +\t}, +\tsso: { +\t\turl: 'http://sso-manager:3001', +\t\tapiToken: ${JSON.stringify(apiToken)}, +\t}, +\tssh: { +\t\tlistenPort: 2222, +\t\thostKeyPath: '/var/lib/jump-host/keys', +\t\tpasswordAuth: 'off', +\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)}, +\t}, +\tweb: { port: 3002 }, +\tauth: { adminGroups: ['app_sso_admin'] }, +\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } }, +\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} }, +}; +`; + fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 }); +} + +async function provisionJumpHost(token) { + if (jumpFileHasToken()) { + log('Jump host: /config/jump-secrets.js already has an API token — keeping.'); + return; + } + const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME); + writeJumpSecrets(apiToken); + log('Jump host: wrote /config/jump-secrets.js (minted directory API token).'); +} + (async function main() { try { log(`Base DN: ${BASE_DN}`); @@ -506,6 +598,17 @@ function writeProxyCreds(id, secret) { resolvedClientId = id; } + // Provision the jump host (mint token + write config) when enabled. + // Warn-only — never fail the whole bring-up over the optional service. + if (JUMP_ENABLED) { + try { + await provisionJumpHost(token); + out('JUMP_HOST_CONFIGURED', '1'); + } catch (e) { + log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`); + } + } + // Seed the directory (site/host/services + OAuth client link). Never // fails the bootstrap — warn and continue. try { diff --git a/docker-compose.yml b/docker-compose.yml index 0f40fa4..ee3b922 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,6 +131,32 @@ 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. + jump-host: + profiles: ["jump-host"] + build: + context: ./jump-host + dockerfile: Dockerfile + args: + GIT_COMMIT: ${JUMP_GIT_COMMIT:-} + container_name: jump-host + restart: unless-stopped + networks: [theta-net] + depends_on: + sso-manager: + condition: service_healthy + ports: + - "${JUMP_SSH_PORT:-2222}:2222" # SSH front door + - "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API + environment: + - NODE_ENV=production + volumes: + - ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap) + - jump-data:/var/lib/jump-host # generated host keys persist here + networks: theta-net: driver: bridge @@ -141,4 +167,5 @@ volumes: sso-data: proxy-data: proxy-cache: - proxy-logs: \ No newline at end of file + proxy-logs: + jump-data: \ No newline at end of file diff --git a/jump-host b/jump-host new file mode 160000 index 0000000..6e6f42e --- /dev/null +++ b/jump-host @@ -0,0 +1 @@ +Subproject commit 6e6f42e8913cd19cf1083b5381749b4a26e0ddf7 diff --git a/setup.env.example b/setup.env.example index c35190b..2469fbb 100644 --- a/setup.env.example +++ b/setup.env.example @@ -33,6 +33,18 @@ 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 +#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) + # Advanced: override the derived LDAP base DN directly (e.g. to namespace # under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN: #CFG_BASE_DN=dc=example,dc=com diff --git a/setup.sh b/setup.sh index 21c590a..f6264b5 100755 --- a/setup.sh +++ b/setup.sh @@ -152,6 +152,19 @@ 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. +[[ -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 + # ── 1. Update submodules to their latest release tag, verify build contexts ─── # Submodules track release tags (vX.Y.Z), not the tip of master -- so # "update" means "move to the newest tag", not "move to the newest commit". @@ -167,8 +180,11 @@ if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then die "git submodule update --init failed. Run manually: git submodule update --init --recursive" fi - info "Updating submodules to their latest release tag (sso-manager-node, proxy)..." - for sm in sso-manager-node proxy; do + # jump-host is optional: only track/build it when enabled. + SUBMODULES=(sso-manager-node proxy) + [[ "$JUMP_ENABLED" == "1" ]] && SUBMODULES+=(jump-host) + info "Updating submodules to their latest release tag (${SUBMODULES[*]})..." + for sm in "${SUBMODULES[@]}"; do [[ -d "$sm" ]] || continue before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)" # Prefer the exact tag the submodule is currently pinned to; fall back @@ -660,6 +676,8 @@ 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:-}" \ sso-manager node /bootstrap/bootstrap.js) \ || die "bootstrap failed:\n${BOOTSTRAP_OUT}" @@ -735,6 +753,46 @@ 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 + info "Building + starting jump-host (optional; enabled via CFG_JUMP_HOST_ENABLED)..." + "${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 "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..." + JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node < { + try { + try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); } + catch (e) { + if (e.name !== 'EntryNotFound') throw e; + await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: true, targetssl: false, sso_enabled: false, created_by: 'setup.sh' }); + console.log('CREATED ${JUMP_HOST} -> jump-host:3002'); + } + process.exit(0); + } catch (error) { console.error('ERROR', error.message); process.exit(1); } +})(); +NODEEOF +) + echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /' +fi + # ── 8. Summary ─────────────────────────────────────────────────────────────── echo info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m" @@ -743,6 +801,11 @@ 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}"