Compare commits

...

69 Commits

Author SHA1 Message Date
wmantly ec625f7cb0 Merge pull request #122 from theta42/release-v1.27.1
Release v1.27.1
2026-08-01 10:27:38 -04:00
wmantly 2a6c7775c9 Bump sso-manager-node to v1.15.2, add integration tests 2026-08-01 10:26:59 -04:00
wmantly 010ff037ce Merge pull request #121 from theta42/release-v1.27.0
Release v1.27.0
2026-08-01 02:45:44 -04:00
wmantly f8f1961b30 Bump sso-manager-node to v1.15.0 and update CHANGELOG for v1.27.0 2026-08-01 02:44:50 -04:00
wmantly 49dee5c477 Merge pull request #120 from theta42/release-v1.26.0
Release v1.26.0: OpenBao production ready
2026-08-01 02:18:03 -04:00
wmantly 1a832d068e Fix shellcheck warning SC2155 2026-08-01 02:17:29 -04:00
wmantly 60ae421cb2 Release v1.26.0: OpenBao production ready 2026-08-01 02:16:33 -04:00
wmantly 90e95bfece Merge pull request #119 from theta42/release-v1.25.0
Release v1.25.0
2026-08-01 01:41:00 -04:00
wmantly 144e97d0e1 Release v1.25.0 2026-08-01 01:40:28 -04:00
wmantly 9dc2de7818 Merge pull request #118 from theta42/release/v1.24.0
Release v1.24.0 - UI polish across all components
2026-07-31 14:45:03 -04:00
wmantly a959b331ae Release v1.24.0 - UI polish across all components
Bumps submodules to their latest releases:
- jump-host v1.13.0: Title changed to 'SSO Manager', TUI picker with ANSI colors
- sso-manager-node v1.13.0: Profile/catalog page redesign, SSH key column fix
- proxy v1.11.0: Table-based list views, auto-refresh groups
2026-07-31 14:43:56 -04:00
wmantly b64083f008 Merge pull request #117 from theta42/release/v1.23.0
Release v1.23.0 - UI polish across all components
2026-07-31 14:08:45 -04:00
wmantly a2fa7a7fc7 Release v1.23.0 - UI polish across all components
Bumps submodules to their latest releases:
- jump-host v1.13.0: Title changed to 'SSO Manager'
- sso-manager-node v1.13.0: Directory page cleanup, users list key column fix
- proxy v1.11.0: Table-based list views, auto-refresh groups
2026-07-31 14:08:00 -04:00
wmantly 6d6aea6011 Merge pull request #116 from theta42/release/v1.22.0
Release v1.22.0 - UI enhancements across all components
2026-07-31 14:01:31 -04:00
wmantly 43e3c79880 Release v1.22.0 - UI enhancements across all components
Bumps submodules to their latest releases:
- jump-host v1.12.0: TUI picker colors, dashboard/audit page styling
- sso-manager-node v1.12.0: Profile page tabs, catalog page redesign
- proxy v1.10.0: Table-based list views, form validation improvements
2026-07-31 13:52:13 -04:00
wmantly 8b549c3315 Release v1.22.0 - UI enhancements across all components
Bumps submodules to their latest releases:
- jump-host v1.12.0: TUI picker colors, dashboard/audit page styling
- sso-manager-node v1.12.0: Profile page tabs, catalog page redesign
- proxy v1.10.0: Table-based list views, form validation improvements

Full changelog entries embedded in each submodule.
2026-07-31 12:57:57 -04:00
wmantly f25a684eb2 Merge pull request #115 from theta42/release/1.21.0
Release 1.21.0: sso-manager-node v1.11.0, ldap-client v1.1.1
2026-07-31 01:31:25 -04:00
wmantly 87d06441c7 Release 1.21.0: sso-manager-node v1.11.0, ldap-client v1.1.1
Bumps the SSO to the release that closes the end-user half of the directory
(catalog, self-service access requests, admin access visibility) and adds
nested LDAP groups, and ldap-client to the release that makes SSSD resolve
that nesting on hosts pointed at a server without the nestgroup overlay.

Operational note: the SSO image now compiles OpenLDAP from a pinned master
commit, because nestgroup exists only on master -- no 2.6.x release ships it.
That makes the image slower to build, and master's LMDB 1.0.0 cannot read the
0.9.x on-disk format from 2.6.x (or vice versa), so moving an existing
/var/lib/ldap onto this image is a slapcat/slapadd reload rather than a
restart. There is a TODO to drop the from-source stage once nestgroup ships
in a release; the entrypoint already probes for it and the app keys off
app_ldap__nestedGroupsServerSide, so that swap needs no other changes.

jump-host and proxy pointers are deliberately unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:30:32 -04:00
wmantly cf1591cdaa Merge pull request #114 from theta42/release/1.20.0
Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
2026-07-30 12:08:25 -04:00
wmantly d4154cfec6 Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
- 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".
2026-07-30 12:07:12 -04:00
wmantly 6e411160a6 Merge pull request #113 from theta42/release/1.19.0
Release 1.19.0: bump submodules, add ldap-test-host fixture
2026-07-30 09:54:15 -04:00
wmantly e25d112787 Release 1.19.0: bump submodules, add ldap-test-host fixture
sso-manager-node -> v1.9.0, proxy -> v1.8.0, jump-host -> v1.10.2,
ldap-client -> v1.0.0 (new)
2026-07-30 09:53:39 -04:00
wmantly 3b5f3423c8 Merge pull request #112 from theta42/feat/jump-host-test-fixture
Add ldap-test-host: a real LDAP-joined downstream host for testing jump-host
2026-07-30 09:48:39 -04:00
wmantly 084c8fdfb3 Add ldap-test-host: a real LDAP-joined downstream host for testing jump-host
New optional service (jump-host compose profile) built from the new
theta42/ldap-client submodule: a genuinely SSSD + AuthorizedKeysCommand
LDAP-joined host, not a container with a manually-dropped public key in
authorized_keys. Lets jump-host's actual key-injection -> upstream-connect
flow be exercised end-to-end against the local dev stack's own LDAP.

Verified: real SSH login through jump-host's uid_-_target grammar, live key
injection into LDAP, and ldap-test-host serving that key via
AuthorizedKeysCommand -- both via the SSH CLI and WinSCP.

config.example/ldap-test-host.vars.example documents the one field an
operator fills in (the ldapclient service account's bind password, already
generated by bootstrap.js as serviceAccountPass) -- the rest points at this
stack's own local LDAP by default.
2026-07-30 09:47:54 -04:00
wmantly f2e924bf58 Merge pull request #111 from theta42/release/1.18.0
Release 1.18.0: bump submodules for API-token UI unification
2026-07-28 21:28:33 -04:00
wmantly 7c0fbd959f Release 1.18.0: bump submodules for API-token UI unification
sso-manager-node -> v1.8.3, proxy -> v1.7.0, jump-host -> v1.10.1
2026-07-28 21:27:59 -04:00
wmantly e1cb855529 Bump sso-manager-node to v1.8.1, proxy to v1.6.0 (#110)
Host modal standardization (tabs onto app.modal, footer, linkable URL) plus
two DOM-rebuild-timing autocomplete-datalist bugs fixed in both apps.
2026-07-28 19:10:33 -04:00
wmantly 51e9e5e42f Bump jump-host to v1.9.0 (Quick Jump UI, audit failDetail) (#109) 2026-07-28 18:17:56 -04:00
wmantly e27439e491 Persist Directory data (Resource/site/host/service/oauth) + bump sso-manager-node to v1.8.0 (#108)
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).
2026-07-28 17:51:08 -04:00
wmantly cf8c5c9a04 Bump jump-host to v1.8.1 (#107) 2026-07-28 15:58:42 -04:00
wmantly c7c0aa8cf5 Persist jump-host's Redis data across rebuilds (#106)
Companion to theta42/jump-host#18: that PR turns on Redis persistence
(AOF + RDB) at /data, but without a volume mount, persistence-within-the-
container is pointless -- docker rm -f (which every real rebuild uses,
per this repo's own documented docker-compose-v1 recreate quirk) throws
the container's whole filesystem away regardless. Adds jump-redis-data:/data,
matching proxy-data's existing role for the same package (lua-resty-auto-ssl
also relies on Redis persistence, via its own volume).

Verified: minted a jump-host API token, force-recreated the container,
confirmed the token still worked afterward.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:47:21 -04:00
wmantly 538b939f9e Merge pull request #105 from theta42/release/1.13.0
Bump sso-manager-node to v1.7.0, proxy to v1.5.3, jump-host to v1.8.0
2026-07-28 14:18:01 -04:00
wmantly d61e661099 Bump sso-manager-node to v1.7.0, proxy to v1.5.3, jump-host to v1.8.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 14:17:21 -04:00
wmantly 81046a186f Merge pull request #104 from theta42/fix/jump-oauth-directory-parent
Fix: theta-jump OAuth client had no parent in the directory
2026-07-28 13:07:52 -04:00
wmantly ab9d9301f0 Fix: theta-jump OAuth client had no parent in the directory
seedDirectory() only ever linked ONE OAuth client -- whatever id was
passed in, which was always the proxy's (resolvedClientId). jump-host's
own OAuth client (minted by provisionJumpHost) was never passed through,
so it was created but never got a ResourceEdge to the "SSH Jump Host"
service resource -- it just showed up in the Directory with no parent.

provisionJumpHost now returns the jump client's id (looking it up even
on the "already configured" early-return path, so existing deployments
self-heal on the next setup.sh run instead of needing this fixed only for
fresh installs), and seedDirectory takes it as a third argument, linking
it under the jump-host service the same way the proxy's client is linked
under the proxy service.

Also fixed live on the affected deployment via the directory-admin API
(created the missing edge directly) rather than waiting for a rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:53:51 -04:00
wmantly ffc8af562a Merge pull request #103 from theta42/release/1.12.0
Bump sso-manager-node to v1.6.3
2026-07-28 01:06:33 -04:00
wmantly a308fc8bbc Bump sso-manager-node to v1.6.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 01:06:01 -04:00
wmantly b6c8fe5a89 Merge pull request #102 from theta42/release/1.11.0
Bump sso-manager-node to v1.6.2, proxy to v1.5.2, jump-host to v1.7.1
2026-07-28 00:23:38 -04:00
wmantly b1cfaa1046 Bump sso-manager-node to v1.6.2, proxy to v1.5.2, jump-host to v1.7.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:23:01 -04:00
wmantly 2f0e291b29 Merge pull request #101 from theta42/test/jump-ldap-tls-check
Add a static consistency check for jump-host's generated LDAP config
2026-07-27 22:42:25 -04:00
wmantly db2db5095b Add a static consistency check for jump-host's generated LDAP config
Regression guard for bootstrap.js's jump-secrets.js template: its ldap
block must use ldaps:// (implicit TLS, :636), not ldap:// (:389), as long
as tlsOptions is set alongside it. ldapts treats a non-empty tlsOptions as
"use implicit TLS" regardless of URL scheme, and jump-host's LDAP client
always sets tlsOptions -- so this exact combination broke every SSH login
to jump-host (any account, any password) before being root-caused
against a real deployment.

Static (parses bootstrap.js as text), not a require()+exec of it --
bootstrap.js is a self-running provisioning script with real side effects
(LDAP writes, live API calls), not a library, so there's nothing safe to
import and call in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:15:47 -04:00
wmantly 79f1f62318 Merge pull request #100 from theta42/release/1.10.0
Bump jump-host to v1.7.0; release notes for the ldaps:// fix
2026-07-27 20:31:04 -04:00
wmantly d8717fd613 Bump jump-host to v1.7.0; release notes for the ldaps:// fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:30:36 -04:00
wmantly f8a213a3bf Merge pull request #99 from theta42/fix/jump-host-ldap-tls
Fix jump-host SSH login: point it at ldaps://, not ldap://
2026-07-27 20:11:28 -04:00
wmantly f90d319eeb Fix jump-host SSH login: point it at ldaps://, not ldap://
jump-secrets.js's ldap.url was 'ldap://sso-manager:389' with tlsOptions
set. ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless
of URL scheme, so every LDAP connection from jump-host opened a raw TLS
handshake against sso-manager's plaintext-LDAP port — slapd dropped the
connection before any LDAP message parsed (visible in slapd.log as
"connection lost" right after ACCEPT, no BIND ever logged). Every SSH
login failed with a generic "Permission denied" for any account, any
password — indistinguishable from a wrong credential.

Root-caused by building a local theta-env stack, restarting jump-host
with edited config, and calling userLdap.getUser/checkPassword directly
inside the container: got "Client network socket disconnected before
secure TLS connection was established" instead of a vague auth failure.
Switching to ldaps://sso-manager:636 (already exposed by the same
container, already what tlsOptions was meant for) fixes it — verified
getUser/checkPassword succeed and a real SSH login authenticates.

Companion defensive fix: simpleworkjs/ldap#1 (rejects this exact
ldap://+tlsOptions combination going forward, for proxy/sso too).

Existing deployments must edit their own ./config/jump-secrets.js (this
template only affects fresh bootstraps) — see the PR description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:10:58 -04:00
wmantly 27ab105325 Merge pull request #98 from theta42/release/1.9.0
Bump sso-manager-node to v1.6.1, proxy to v1.5.1
2026-07-27 17:47:02 -04:00
wmantly 5aaec1b18a Bump sso-manager-node to v1.6.1, proxy to v1.5.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:46:26 -04:00
wmantly 8c7648781e Merge pull request #97 from theta42/feature/http-proxy-support
Add optional upstream HTTP(S) proxy support for the docker stack
2026-07-27 14:42:15 -04:00
wmantly 46815e681c Add optional upstream HTTP(S) proxy support for the docker stack
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>
2026-07-27 14:41:38 -04:00
wmantly a888624f38 Merge pull request #96 from theta42/release/1.8.0
Bump sso-manager-node to v1.6.0, proxy to v1.5.0, jump-host to v1.6.0
2026-07-27 14:22:21 -04:00
wmantly 191ef0a55f Bump sso-manager-node to v1.6.0, proxy to v1.5.0, jump-host to v1.6.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:21:49 -04:00
wmantly 128083aee6 Merge pull request #95 from theta42/release/v1.7.0
Release 1.7.0: bump sso-manager-node to v1.5.1, jump-host to v1.5.0
2026-07-26 23:39:19 -04:00
wmantly f59f987115 Bump sso-manager-node to v1.5.1, jump-host to v1.5.0
Fixes the sshPublicKey ObjectClassViolationError (both in sso-manager-node's
API and jump-host's key-injection path) and the blank OAuth parent
dropdown; adds a host list to jump-host's dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:38:55 -04:00
wmantly 71e8c09e8f Merge pull request #94 from theta42/release/v1.6.0
Release 1.6.0: bump jump-host to v1.4.0 (standalone mode)
2026-07-26 21:40:19 -04:00
wmantly acc61a4c3d Bump jump-host to v1.4.0 (standalone mode)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:39:57 -04:00
wmantly 67f62276e7 Merge pull request #93 from theta42/docs/screenshots-refresh
docs: refresh top-level screenshots, add jump-host dashboard
2026-07-26 16:32:06 -04:00
wmantly 6ef12408df docs: refresh top-level screenshots, add jump-host dashboard
sso-dashboard.png and proxy-hosts.png still showed the pre-unification
nav; refresh with the current shared shell and add a jump-host dashboard
screenshot alongside them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:29:51 -04:00
wmantly c3d232f7cc Merge pull request #92 from theta42/release/v1.5.0
Release 1.5.0: unified front-end UI shell across the three apps
2026-07-26 00:33:22 -04:00
wmantly 285cc4fbef Release 1.5.0: bump submodules to the unified-UI-shell tags
sso-manager-node v1.5.0, proxy v1.4.0, jump-host v1.3.0 — the three apps
now share one byte-identical front-end shell (top.ejs, bottom.ejs,
app-base.js) with per-app values in each repo's utils/ui.js, one nav-gating
model driven by /api/user/me, and jQuery 4 / EJS 3 across the board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:32:37 -04:00
wmantly 5299556057 Merge pull request #91 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:44:08 -04:00
wmantly cc03b3758c Release 1.4.0: bump submodules to the unified-release tags
sso-manager-node -> v1.4.0, proxy -> v1.3.0, jump-host -> v1.2.0. The three
apps now share @simpleworkjs/{oidc-client,directory-schema,ldap,app-stack}
(1.0.0, published under the simpleworkjs org) instead of byte-identical forks,
and the SSO directory discovery API no longer leaks client_secret_hash and
returns the {results} envelope (fixing jump-host bridging). No setup.sh change:
the new deps resolve from npm in each app's image build (npm ci stays clean).
CHANGELOG embeds the full per-app release notes. UI chrome unification is
deferred to a browser-verified session (see UI_UNIFICATION_HANDOFF.md).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:43:08 -04:00
wmantly 2d0496cfda Merge pull request #90 from theta42/release/v1.3.7
Release 1.3.7
2026-07-23 21:02:46 -04:00
wmantly 48df638ddd Release 1.3.7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:02:22 -04:00
wmantly d6d2c7144a Merge pull request #89 from theta42/feature/jump-oidc
Provision jump-host web-UI SSO login (OIDC client)
2026-07-23 21:02:06 -04:00
wmantly 67374dc914 feat: provision jump-host web-UI SSO login (OIDC client); bump to v1.1.0
The jump host's web UI now authenticates via OIDC + a local admin
(jump-host v1.1.0). Wire that in the bundle:

- bootstrap mints a dedicated 'theta-jump' OAuth client (redirect
  https://<JUMP_HOST>/api/auth/oidc/callback) and writes a full oidc
  block + generated local admin password into config/jump-secrets.js,
  mirroring the proxy's OIDC provisioning
- an existing pre-OIDC jump-secrets.js (API token but no OIDC client) is
  regenerated so upgraders get SSO login
- bump jump-host submodule v1.0.x -> v1.1.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:01:36 -04:00
wmantly a3b41c6775 Merge pull request #88 from theta42/docs/jump-host
docs: add the optional SSH jump host to the Pages site
2026-07-23 16:23:35 -04:00
wmantly b25fb56a0d docs: add the optional SSH jump host to the theta-env Pages site
- index: mention the jump host as an optional third component (intro,
  What-you-get, Related projects)
- quickstart: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT
- reframe "legacy LDAP clients" -> "direct LDAP clients" (Linux hosts are
  first-class consumers)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:17:14 -04:00
wmantly a15002b588 Merge pull request #87 from theta42/release/v1.3.6
Release 1.3.6: bump sso-manager-node to v1.3.2 (bootstrap fix)
2026-07-23 16:10:47 -04:00
wmantly fe8133b21c Release 1.3.6: bump sso-manager-node to v1.3.2
Fixes the OAuth-client-API client_id serialization bug that broke this
stack's bootstrap (rotate -> 500 -> 'bootstrap failed'). See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:10:20 -04:00
23 changed files with 1114 additions and 49 deletions
+6 -1
View File
@@ -2,7 +2,9 @@ name: Lint
# theta-env has no app code of its own to unit-test (it orchestrates the
# proxy/sso-manager-node submodules) -- this checks the one thing that can
# actually break silently: setup.sh and bootstrap.js.
# actually break silently: setup.sh and bootstrap.js, plus a static
# consistency check on the config bootstrap.js generates for jump-host
# (test/check_jump_ldap_tls.js).
on:
pull_request:
branches:
@@ -39,3 +41,6 @@ jobs:
- name: Syntax check
run: node --check bootstrap/bootstrap.js
- name: Jump-host LDAP config consistency
run: node test/check_jump_ldap_tls.js
+6 -2
View File
@@ -5,8 +5,12 @@
config/
backups/
# Legacy .env / proxy.env (no longer used — config is in ./config/). Still
# ignored in case a migrated deployment hasn't deleted them yet.
# .env: NOT app config (that's ./config/, generated by setup.sh) — this is
# docker compose's own auto-loaded env file, which setup.sh uses only to
# persist *_GIT_COMMIT build args so an ad-hoc rebuild of a single service
# still bakes the right commit hash. Generated; never commit.
# proxy.env: legacy, no longer used — still ignored in case an old
# deployment hasn't deleted it yet.
.env
proxy.env
+3
View File
@@ -8,3 +8,6 @@
path = jump-host
url = https://github.com/theta42/jump-host.git
branch = master
[submodule "ldap-client"]
path = ldap-client
url = https://github.com/theta42/ldap-client.git
+624 -1
View File
@@ -8,8 +8,630 @@ 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.27.1] - 2026-08-01
- Added automated integration tests for the environment (`test-integration.sh`).
- Updated `sso-manager-node` to v1.15.2 (Directory UI tab styling fixes and Vault documentation).
## [v1.27.0] - 2026-08-01
- Updated `sso-manager-node` to v1.15.0 (UI/UX improvements and structured conf page).
## [v1.26.0] - 2026-08-01
- Made OpenBao production-ready by using a persistent file backend, enabling `IPC_LOCK`, and dynamically generating a robust config file.
- Automated OpenBao initialization, unsealing, and secrets seeding via `setup.sh`.
## [v1.25.0] - 2026-08-01
- Added OpenBao (Vault) container for secrets management and native UI proxying.
- Updated `sso-manager-node` to v1.14.0 (Discovery and Vault integration).
- Updated `proxy` to v1.12.0.
## [Unreleased]
## [1.23.0] - 2026-07-31
### Submodules bumped
- jump-host `v1.12.0` -> [`v1.13.0`](https://github.com/theta42/jump-host/releases/tag/v1.13.0)
- proxy `v1.10.0` -> [`v1.11.0`](https://github.com/theta42/proxy/releases/tag/v1.11.0)
- sso-manager-node `v1.12.0` -> [`v1.13.0`](https://github.com/theta42/sso-manager-node/releases/tag/v1.13.0)
#### jump-host — [v1.13.0](https://github.com/theta42/jump-host/releases/tag/v1.13.0)
##### Changed
- **Title changed to "SSO Manager"** — the jump-host web UI now presents itself as "SSO Manager" in the navbar and page title, matching its role as the unified access portal for both services and hosts.
#### sso-manager-node — [v1.13.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.13.0)
##### Changed
- **Directory page cleaned up** — removed the parent badge and slug display from the directory table; resource names now align with the badges above for a cleaner, more compact layout.
- **Users list SSH key column fixed** — users with multiple SSH keys no longer show multiple checkmarks; the column now shows a single checkmark indicating "has key" regardless of key count.
#### proxy — [v1.11.0](https://github.com/theta42/proxy/releases/tag/v1.11.0)
##### Changed
- **Permissions, Users, and Groups pages converted to table layouts** — card grids replaced with striped tables for better scanability and alignment. Users page adds per-field validation error display alongside the summary message.
- **Groups page auto-refreshes** — adding or removing a group now triggers an explicit reload, ensuring the list stays in sync without manual refresh.
## [1.22.0] - 2026-07-31
### Submodules bumped
- jump-host `v1.11.0` -> [`v1.12.0`](https://github.com/theta42/jump-host/releases/tag/v1.12.0)
- proxy `v1.9.0` -> [`v1.10.0`](https://github.com/theta42/proxy/releases/tag/v1.10.0)
- sso-manager-node `v1.11.0` -> [`v1.12.0`](https://github.com/theta42/sso-manager-node/releases/tag/v1.12.0)
#### jump-host — [v1.12.0](https://github.com/theta42/jump-host/releases/tag/v1.12.0)
##### Added
- **TUI host picker with colors**: ANSI-colored terminal UI with box-drawing header, cyan/magenta/green title treatment, per-row coloring (cyan hostnames, blue IPs), environment badges (red PROD / dim DEV), green inverse selection highlight with "◄ SELECTED ►" indicator, yellow filter text, and a footer separator with quick-select hint.
#### sso-manager-node — [v1.12.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.12.0)
##### Changed
- **Catalog page (`/`) redesigned**: Removed the portal banner; "My Access" section now has tabs separating Services and Hosts; icons support both Font Awesome classes and image URLs (http/https).
- **Profile page redesigned as a single card with tabs**: Password reset is now a modal button; "My groups", "My Services", "Security & Usage Stats", and "Members of X's group" are now tabs on the main profile card instead of separate cards; metrics display fixed to properly load and show service usage data.
#### proxy — [v1.10.0](https://github.com/theta42/proxy/releases/tag/v1.10.0)
##### Changed
- **Permissions page**: Converted from card grid to table/list layout with columns: Subject, Scope, Domain, Role, Actions.
- **Users page**: Converted from card grid to table/list layout; form validation now shows both a summary message AND per-field error messages with visual highlighting.
- **Groups page**: Added automatic refresh after adding/deleting groups to ensure new entries appear immediately.
## [1.21.0] - 2026-07-31
### Submodules bumped
- ldap-client `v1.1.0` -> [`v1.1.1`](https://github.com/theta42/ldap-client/releases/tag/v1.1.1)
- sso-manager-node `v1.10.0` -> [`v1.11.0`](https://github.com/theta42/sso-manager-node/releases/tag/v1.11.0)
> **Operational note — the SSO image now builds slapd from source.** OpenLDAP's
> `nestgroup` overlay (nested groups) exists only on master; no 2.6.x release
> ships it. `Dockerfile.openldap` therefore compiles OpenLDAP from a **pinned**
> commit, which makes the SSO image slower to build and pulls `pw-sha2` from
> contrib. Two consequences worth knowing:
>
> - Master ships **LMDB 1.0.0**, whose on-disk format is mutually unreadable
> with the 0.9.x in 2.6.x (`MDB_INVALID: File is not an LMDB file`). Moving an
> existing `/var/lib/ldap` onto this image is a `slapcat` -> `slapadd` reload,
> not a restart.
> - There is a `TODO` to drop the whole from-source stage once `nestgroup` ships
> in a release. The entrypoint already probes for `nestgroup.so` and the app
> keys off `app_ldap__nestedGroupsServerSide`, so that swap needs no other
> changes.
#### sso-manager-node — [v1.11.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.11.0)
##### Added
- **End-user catalog at `/`** — the first ungated nav item; previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a *how to reach it* block per card: the URL for a service, the SSH invocation for a host (using the jump-host `uid_-_slug@host` grammar when `directory.jumpHost` is set).
- **Self-service access requests** — `/api/access-requests` (create, list own, list decidable, approve, deny, withdraw) with approve/deny queues on the catalog. Approving performs the LDAP group add, so LDAP stays the access-control truth. Requests target a resource's `_access` group, never `_admin`. Replaces the "coming soon" stub.
- **Admin access visibility** — an Access column on the directory table (member/group counts, flagging links whose LDAP group was deleted) and a "what can this user reach" lookup, the reverse question that previously had no UI at all.
- **Nested LDAP groups.** `groupOfNames.member` already accepts a group DN, so nesting needs no schema — what it needs is resolution, which no released OpenLDAP performs. Server-side via the pinned-master `nestgroup` overlay; client-side the app computes the closure itself (cycle-detected, depth-capped) against any other server. `PUT`/`DELETE /api/group/:group/nested/:child`, `GET /api/group/:group/effective`, and a **Nested** tab on each group card.
- **`app_super_admin` is now seeded** (it never was) and nested into `app_sso_admin` / `app_sso_invite` / `app_sso_oauth_admin`, so the privilege is real LDAP membership visible to SSSD and sudo rather than a special case in app code. Resource creation nests `app_super_admin` -> `<slug>_admin` and `<slug>_admin` -> `<slug>_access`.
- Resource metadata `icon` and `tagline`, collected on the admin form with a live icon preview.
##### Fixed
- **`GET /api/discovery/me` returned only `isPublic` resources for every human caller** — it read `req.user.groups`, which does not exist (`req.user` carries `memberOf`), so the empty list failed open. "My Services" was blank for everyone, and `isDirectoryAdmin()` was false even for real directory admins.
- **The portal's "Discover More Services" was dead for every non-admin** — it called the admin-gated endpoint and swallowed the 403 into an empty array.
- **Services reported no address** — `/me` had reimplemented `getMyAccess` without its parent-walking resolution, so a service reached at its host's IP resolved to nothing.
- `GET /api/user/me` derived `isAdmin` from `memberOf`, which is only transitive with `nestgroup`; against a stock server an admin holding their group via nesting lost the entire admin UI while still passing every server-side check.
- `utils/permission.js`'s `byGroup` saw only direct membership.
- Adding an existing group member, and removing a group's last member, both returned bare 500s; now 409s that explain themselves.
- `DELETE /api/directory-admin/resources/:id` deleted the resource before its edges and group links, orphaning rows on a mid-way failure.
- `/api/directory-admin/audit-logs` shelled out to `tail` via `execSync`; replaced with a bounded async read.
- Broken `api.html` link in the published docs.
##### Changed
- `@simpleworkjs/directory-schema` -> `^1.1.0`, declaring ten metadata keys the admin form always wrote but the schema never listed. Undeclared keys are dropped for non-admin callers — which blanked the portal's `OS:` field, hid every service's port, and left machine tokens unable to read the port mapping the firewall consumer exists to render.
#### ldap-client — [v1.1.1](https://github.com/theta42/ldap-client/releases/tag/v1.1.1)
##### Fixed
- Sets `ldap_group_nesting_level = 5` so SSSD walks nested groups itself when pointed at a server without `nestgroup`. Against the SSO's bundled slapd the existing `memberof=` access filter is already transitive, so SSH login inherits nesting for free. The explicit `app_super_admin` clause is kept, to keep super-admin login working against a directory predating the new nesting.
## [1.20.0] - 2026-07-30
### Added
- **`setup.sh` persists `SSO_GIT_COMMIT`/`PROXY_GIT_COMMIT`/`JUMP_GIT_COMMIT` into `./.env`** (new `env_upsert` helper), which `docker compose` auto-loads on every future invocation in this directory. Previously these were only `export`ed for the current shell, so an ad-hoc `docker compose up --build <service>` run later (outside a full `setup.sh` run) would build with an empty `GIT_COMMIT` arg — and since each submodule's checked-out `.git` is a pointer file, not a real repo, the in-container git fallback can't resolve it either, so the image silently baked "unknown" as its commit hash. `.gitignore`'s `.env` comment updated to describe this (it was previously labeled "legacy, no longer used").
### Submodules bumped
- jump-host `v1.10.2` -> [`v1.11.0`](https://github.com/theta42/jump-host/releases/tag/v1.11.0)
- ldap-client `v1.0.0` -> [`v1.1.0`](https://github.com/theta42/ldap-client/releases/tag/v1.1.0)
- proxy `v1.8.0` -> [`v1.9.0`](https://github.com/theta42/proxy/releases/tag/v1.9.0)
- sso-manager-node `v1.9.0` -> [`v1.10.0`](https://github.com/theta42/sso-manager-node/releases/tag/v1.10.0)
#### sso-manager-node — [v1.10.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.10.0)
##### Added
- `app_super_admin` cross-app group: members are full admins here regardless of `app_sso_admin` membership. The same group is now also recognized by proxy and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
##### Changed
- Renamed the Executive page to Overview (route, view, `/api/metrics/overview`, nav label, docs). `/executive` kept as a 301 redirect.
#### proxy — [v1.9.0](https://github.com/theta42/proxy/releases/tag/v1.9.0)
##### Added
- `app_super_admin` cross-app group recognized as a global admin (`conf.auth.adminGroups`).
##### Changed
- Users and Permissions pages: the always-visible sidebar "Add" forms are now an "Add User"/"Add Permission" button that opens an `app.modal` dialog, matching the hosts.ejs convention.
- Let's Encrypt ACME account key now defaults to the already-persisted `/data` volume instead of a CWD-relative path (`./le_key.cert` -> `/app/le_key.cert` in the container), which was lost on every image rebuild.
#### jump-host — [v1.11.0](https://github.com/theta42/jump-host/releases/tag/v1.11.0)
##### Added
- `app_super_admin` (cross-app) and `app_jump_admin` groups: super admins are full admins here same as `app_sso_admin`; jump admins get audit page/data access without other admin rights. The Audit page/API is now actually admin-gated server-side (previously the page shell rendered for any logged-in user, only its data was gated).
- Host list adds Last connection/Last failed connection columns and highlights rows green (a session is live right now) or yellow (the most recent attempt failed), backed by new per-host last-success/last-fail timestamps.
##### Changed
- Dashboard's stat boxes and Top hosts/Top users cards moved to the Audit page. "All hosts" renamed to "My hosts".
#### ldap-client — [v1.1.0](https://github.com/theta42/ldap-client/releases/tag/v1.1.0)
##### Added
- `app_super_admin` cross-app group now grants SSH login access to every host (`ldap_access_filter` + `ldap_access_groups`), matching the same group's admin rights in sso-manager-node, proxy, and jump-host. Sudo is not yet extended to super admins (`ldap_sudo_search_filter` remains non-functional on this SSSD version — pre-existing, documented gap).
## [1.19.0] - 2026-07-30
### Added
- **New `ldap-client` submodule + `ldap-test-host` service** (`jump-host` compose profile): a real SSSD + AuthorizedKeysCommand LDAP-joined downstream host for testing jump-host's actual key-injection -> upstream-connect flow end-to-end against this stack's own local LDAP, instead of a container with a manually-dropped public key in `authorized_keys`. Verified live (SSH CLI and WinSCP) through jump-host's `uid_-_target` grammar.
#### ldap-client — [v1.0.0](https://github.com/theta42/ldap-client/releases/tag/v1.0.0) (first tagged release)
##### Added
- Docker test fixture (`Dockerfile` + `entrypoint.sh`): Ubuntu 22.04 + sssd + sshd, no systemd required.
##### Fixed
Building that fixture surfaced three real bugs that would break login on any deployment, not just the test fixture:
- `sssd.conf.mo` used `ldap_bind_dn`/`ldap_bind_pw`, which aren't real SSSD options — corrected to `ldap_default_bind_dn` / `ldap_default_authtok(_type)`.
- `sssd.conf.mo` had no explicit `services =` list, so SSSD started only its backend, never the nss/pam responders — `getent passwd <ldap-user>` silently failed even with the domain reachable.
- `ldap-ssh-key.sh`'s `memberof` filter was missing the `cn=` prefix on the group name, so the AuthorizedKeysCommand script always returned zero keys for a correctly-provisioned user — no error, just silently nothing.
#### sso-manager-node — [v1.9.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.9.0)
##### Added
- Directory modal's Associated LDAP Groups tab now supports full membership management: view, add, and remove members/owners of each associated group directly from the tab.
- `app.util.revealItem()` (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background.
##### Changed
- Groups page's search/sort bar is now sticky while scrolling.
- Directory table: Kind/Name/Env/Host merged into a single "Resource" column.
#### proxy — [v1.8.0](https://github.com/theta42/proxy/releases/tag/v1.8.0)
##### Added
- Users backed by SSO/OIDC login are now marked "External (SSO)" and read-only (password-change hidden client-side, `PUT /password/:username` rejects with 403 server-side). Redis user-backend only.
##### Changed
- All pages now wrap their content in a standard-width container, matching sso-manager-node instead of rendering full-bleed.
- Users and Permissions pages converted from bare `<table>`s to the card-grid convention already used on the Groups page.
#### jump-host — [v1.10.2](https://github.com/theta42/jump-host/releases/tag/v1.10.2)
##### Changed
- Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width.
- Audit's nav entry is now admin-gated (`groups: ['admin']`).
### Bumped
- sso-manager-node -> [v1.9.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.9.0)
- proxy -> [v1.8.0](https://github.com/theta42/proxy/releases/tag/v1.8.0)
- jump-host -> [v1.10.2](https://github.com/theta42/jump-host/releases/tag/v1.10.2)
- ldap-client -> [v1.0.0](https://github.com/theta42/ldap-client/releases/tag/v1.0.0) (new submodule)
## [1.18.0] - 2026-07-28
### Changed
Cross-app API-token self-service UI unification: all 3 apps now share the
same card-grid list, "+ New Token" modal-based create flow, `app.modal`-based
secret reveal, and Edit modal (with real created-by/on audit metadata).
#### sso-manager-node — [v1.8.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.8.2), [v1.8.3](https://github.com/theta42/sso-manager-node/releases/tag/v1.8.3)
**v1.8.2**
##### Fixed
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
**v1.8.3**
##### Changed
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>``<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
#### proxy — [v1.7.0](https://github.com/theta42/proxy/releases/tag/v1.7.0)
##### Added
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
##### Fixed
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
#### jump-host — [v1.10.0](https://github.com/theta42/jump-host/releases/tag/v1.10.0), [v1.10.1](https://github.com/theta42/jump-host/releases/tag/v1.10.1)
**v1.10.0**
##### Added
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
##### Changed
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
**v1.10.1**
##### Fixed
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
### Bumped
- sso-manager-node -> [v1.8.3](https://github.com/theta42/sso-manager-node/releases/tag/v1.8.3)
- proxy -> [v1.7.0](https://github.com/theta42/proxy/releases/tag/v1.7.0)
- jump-host -> [v1.10.1](https://github.com/theta42/jump-host/releases/tag/v1.10.1)
## [1.17.0] - 2026-07-28
### Added
- **proxy's host modal now has a footer (created/updated-by/on metadata) and a linkable `/hosts/{host}` URL**, migrated onto the same shared `app.modal` component as sso-manager-node's resource modal — continuing the entity-modal standardization across the stack.
### Fixed
- **proxy: the Let's-Encrypt challenge-type/wildcard-matching visibility logic could stop reacting to the hostname field after the first Add/Edit host**, and **the SSO allow-list autocomplete could go empty starting on the second Add/Edit** — both were DOM-rebuild timing bugs in the same class as the resource-modal fixes already shipped.
- **sso-manager-node: the resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — same DOM-rebuild timing bug, now fixed.
### Bumped
- sso-manager-node -> [v1.8.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.8.1)
- proxy -> [v1.6.0](https://github.com/theta42/proxy/releases/tag/v1.6.0)
## [1.16.0] - 2026-07-28
### Added
- **"Quick Jump" copy-to-clipboard section on the jump-host dashboard** — one-click-copy SSH commands (interactive-picker mode, plus a per-host `uid_-_target` grammar-mode command) instead of having to remember/reconstruct the format by hand.
### Fixed
- **jump-host audit records for a failed downstream connection only ever said `upstream-unreachable`**, with no way to tell a network-layer failure from an auth failure — the real error (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) is now captured and shown as a tooltip on the audit table's fail badge.
### Bumped
- jump-host -> [v1.9.0](https://github.com/theta42/jump-host/releases/tag/v1.9.0)
## [1.15.0] - 2026-07-28
### Fixed
- **sso-manager's Directory data (every site/host/service/oauth-client resource and their relationships/LDAP-group associations) had no persistent volume** — `@simpleworkjs/orm` fell back to `./config/inventory.sqlite` (relative to the app's `/app` cwd) whenever `conf.orm` wasn't set, which sits in the container's ephemeral writable layer, not any mounted volume. Every container recreate (`docker compose up --build`, `down`/`up`, an image rebuild) silently wiped the entire Directory Management page. `setup.sh`'s generated `sso-secrets.js` (and the example template) now set `orm: { dialect: 'sqlite', storage: '/data/inventory.sqlite' }`, co-locating it with the already-persisted `sso-data` volume (where Redis lives). **Existing deployments**: this repo doesn't rewrite an operator's existing `config/sso-secrets.js` (re-running `setup.sh` leaves it untouched by design) — add the `orm` block above manually, and copy the container's current `/app/config/inventory.sqlite` to `/data/inventory.sqlite` *before* recreating the container, or the existing Directory data will be lost on the next recreate instead of migrated.
### Bumped
- sso-manager-node -> [v1.8.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.8.0)
## [1.14.0] - 2026-07-28
### Fixed
- **jump-host's Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation (including a `setup.sh` re-run) silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is the root cause of the reported "re-running setup.sh breaks OAuth with jump" — the jump-host container gets recreated, and any token or in-flight login vanished with it, while proxy was unaffected because its Redis was already persisted. Now jump-host's Redis persists (AOF + periodic RDB) to `/data`, mounted as a new named volume, `jump-redis-data`. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
### Changed
- `docker-compose.yml`: added the `jump-redis-data` volume, mounted at `/data` on the `jump-host` service.
### Bumped
- jump-host -> [v1.8.1](https://github.com/theta42/jump-host/releases/tag/v1.8.1)
## [1.13.0] - 2026-07-28
### Fixed
Found via feedback on a fresh install:
- **jump-host's OAuth client had no parent in the Directory.** `seedDirectory()` only ever linked the proxy's OAuth client; jump-host's own (minted by `provisionJumpHost`) was created but never passed through, so it never got a `ResourceEdge`. Existing deployments self-heal on the next `setup.sh` run.
- **TUI-mode SSH connections (bare `ssh user@host`) could drop** with "PTY allocation request failed" / "shell request failed" — a session-listener race in jump-host, same class of bug `runGrammar` already had a fix for.
- **Every form submit briefly showed literal HTML** instead of a loading spinner, across all three apps.
- **`POST /api/user/` and `PUT /api/user/password` had no success message** — a green notification with nothing in it right after adding a user.
- **The login page gave no explanation for why the user landed there** when redirected mid-OAuth-flow.
### Changed
- **Directory: tree view is now the only view; clicking a resource's name opens its detail modal.**
### Bumped
- sso-manager-node -> [v1.7.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.7.0)
- proxy -> [v1.5.3](https://github.com/theta42/proxy/releases/tag/v1.5.3)
- jump-host -> [v1.8.0](https://github.com/theta42/jump-host/releases/tag/v1.8.0)
No `setup.sh` or compose change. Also confirmed (no fix needed): the Let's Encrypt ACME account key persists correctly across container rebuilds — `lua-resty-auto-ssl`'s Redis storage adapter writes through the bundled Redis, which is started with `--appendonly yes` into `/data`, mapped to the persisted `proxy-data` volume. Only an explicit `docker-compose down -v` / volume removal would lose it (which is also what's required, and expected, on a domain change).
## [1.12.0] - 2026-07-28
### Bumped
- sso-manager-node -> [v1.6.3](https://github.com/theta42/sso-manager-node/releases/tag/v1.6.3) — fixes the root cause of a real "lost user" report: `routes/group.js` never invalidated the User cache on membership changes, so an account added to the `app_sso_service_account` marker group (which hides accounts from the Users page's People tab) could look like it had vanished for up to 5 minutes — and, separately, could be added to that group with no warning at all. Both fixed; see the linked release for detail.
No `setup.sh` or compose change.
## [1.11.0] - 2026-07-28
### Added
- **`test/check_jump_ldap_tls.js`**, wired into the `Lint` workflow: a static consistency check on the jump-secrets.js template `bootstrap.js` generates, so the `ldap://` + `tlsOptions` mistake that broke every SSH login in 1.10.0 fails CI before it ever reaches a real deployment again.
- **A static "no native `alert()`/`confirm()`/`prompt()`" check** is now part of all three apps' own test suites (they block all further browser events on the page — see 1.9.0/1.10.0's release notes).
### Bumped
- sso-manager-node -> [v1.6.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.6.2) — fixes `DELETE /api/oauth/client/:id` (`client.remove is not a function`, a genuine 500 masked by tests that never checked the response status), plus the regression test above.
- proxy -> [v1.5.2](https://github.com/theta42/proxy/releases/tag/v1.5.2) — the regression test above.
- jump-host -> [v1.7.1](https://github.com/theta42/jump-host/releases/tag/v1.7.1) — the regression test above.
No `setup.sh` or compose change.
## [1.10.0] - 2026-07-27
### Fixed
- **`bootstrap/bootstrap.js`'s jump-secrets.js template now points jump-host at `ldaps://sso-manager:636`**, not `ldap://sso-manager:389`. The plain-port URL combined with jump-host's `tlsOptions` made `ldapts` attempt implicit TLS against a port serving plaintext LDAP — slapd dropped every connection before any LDAP message parsed, so SSH password login failed for every account, with any password, indistinguishable from a wrong credential. Root-caused by standing up a local jump-host, editing its config, and calling `getUser`/`checkPassword` directly inside the container. **Existing deployments must edit `./config/jump-secrets.js` themselves** (this template only affects fresh bootstraps) — see theta42/theta-env#99. Companion defensive fix: [simpleworkjs/ldap v1.0.2](https://github.com/simpleworkjs/ldap/releases/tag/v1.0.2) now rejects this `ldap://` + `tlsOptions` combination outright.
### Bumped
- jump-host -> [v1.7.0](https://github.com/theta42/jump-host/releases/tag/v1.7.0) — adds self-service API tokens (create/list/rotate/revoke from its dashboard); jump-host previously had none.
No `setup.sh` or compose change.
## [1.9.0] - 2026-07-27
### Bumped
- sso-manager-node -> [v1.6.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.6.1)
- proxy -> [v1.5.1](https://github.com/theta42/proxy/releases/tag/v1.5.1)
Both apps had every native `alert()`/`confirm()` call removed, replaced by
`@simpleworkjs/frontend`'s `app.messages.action`/`confirm`/`toast` (the
same modules adopted in [1.8.0](#180---2026-07-27)). This was found live,
mid browser-verification of that release: clicking sso-manager-node
directory.ejs's "Rotate Client Secret" triggered a native `confirm()`,
which blocks all further browser events on the page — a real hazard for
anyone driving the app with browser automation, not just a cosmetic
inconsistency. sso-manager-node also dropped `app.user.remove`/
`app.oauthClient.remove` from `public/js/app.js` (dead code with a native
`confirm()` guard and zero callers).
No `setup.sh`, compose, or config change.
## [1.8.0] - 2026-07-27
### Bumped
- sso-manager-node -> [v1.6.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.6.0)
- proxy -> [v1.5.0](https://github.com/theta42/proxy/releases/tag/v1.5.0)
- jump-host -> [v1.6.0](https://github.com/theta42/jump-host/releases/tag/v1.6.0)
All three apps adopt the newly published `@simpleworkjs/frontend` package's
`app.messages`, `app.modal`, and `app.validate` modules, replacing the
vendored `app.util.actionMessage`/`actionConfirm`/`alert` in
`public/lib/js/app-base.js` (byte-identical across all three apps) and the
vendored `public/lib/js/val.js` (byte-identical in sso-manager-node and
jump-host, and the same engine plus proxy-only DNS/hostname rules in proxy).
Message content is now HTML-escaped — the ad hoc `app.util.alert()` this
replaces had none — and `app.messages.action` falls back to a page-wide
toast when there's no inline `.actionMessage` target on the page. proxy's
`host`/`target`/`hostname` wildcard-DNS validation rules (mirroring
`utils/hostname_validate.js`) move to its own `public/js/app.js`, registered
via `$.validateSettings`, since they're proxy-specific and don't belong in
the shared package's generic rule set (`eq`/`user`/`password`/`ip`).
jump-host doesn't currently use any `[validate]` attributes, so its `val.js`
swap is dedup/future-proofing rather than a behavior change.
`app.api`/`app.auth`/`app.pubsub`/`app.socket` in each app's `app-base.js`
are untouched: they're app-specific (a dual-mode callback/promise API with
`auth-token` header injection) and not something the frontend package's
generic `app.js` provides, so it isn't loaded.
No `setup.sh`, compose, or config change.
## [1.7.0] - 2026-07-27
### Bumped
- sso-manager-node -> [v1.5.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.5.1)
- jump-host -> [v1.5.0](https://github.com/theta42/jump-host/releases/tag/v1.5.0)
Two production bugs fixed: `PUT /api/user/:uid` 500'd with an LDAP
`ObjectClassViolationError` when setting `sshPublicKey` on any account
predating the `ldapPublicKey` objectClass (notably the bootstrap admin) —
and the exact same bug, in the shared `@simpleworkjs/ldap` package's
`addSshKey`, was silently aborting SSH connections at jump-host's
key-injection step for the same class of accounts. Both are fixed by
ensuring the objectClass is present before writing the attribute. Also
fixed: the Directory's "add resource" modal left the parent-Service
dropdown blank when adding an OAuth Integration.
Jump-host's web dashboard also gained a "Hosts you can reach" list
(admins see "All hosts") — previously it only showed usage metrics with
no way to see your actual access from the browser.
No `setup.sh`, compose, or config change.
## [1.6.0] - 2026-07-26
### Bumped
- jump-host -> [v1.4.0](https://github.com/theta42/jump-host/releases/tag/v1.4.0)
Jump-host gains **standalone mode**: it can now run with no LDAP directory and
no SSO Manager at all, storing users and hosts itself via
`@simpleworkjs/orm` (Sequelize; SQLite by default, any Sequelize-supported
dialect). This is an app-internal capability, opt-in via
`standalone.enabled` in jump-host's own config — the bundled theta-env stack
is unaffected and continues to wire jump-host to the shared LDAP directory
and SSO Manager as before. Two bugs were also fixed in jump-host's SSH
server: an ephemeral listen port (`0`) was silently overridden back to the
default, and session listeners could miss a client's immediate `exec`/`shell`
request.
No `setup.sh`, compose, or config change on the theta-env side.
## [1.5.0] - 2026-07-26
### Bumped
- sso-manager-node -> [v1.5.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.5.0)
- proxy -> [v1.4.0](https://github.com/theta42/proxy/releases/tag/v1.4.0)
- jump-host -> [v1.3.0](https://github.com/theta42/jump-host/releases/tag/v1.3.0)
This release finishes the UI half of the unification that 1.4.0 deferred: the
three apps now share one front-end shell. `views/top.ejs`, `views/bottom.ejs`
and `public/lib/js/app-base.js` are byte-identical across sso-manager-node,
proxy and jump-host, and everything per-app moved into each repo's new
`nodejs/utils/ui.js` (nav items and the groups that may see them, footer links,
favicon, profile/logout targets, update-banner on/off). Nav gating is one model
everywhere — the shell reveals `.group-required-<cn>` from `GET /api/user/me`,
normalising sso's LDAP DNs and the OIDC clients' group CNs to the same shape,
with the clients' `isAdmin` flag exposed as a synthetic `admin` group. jQuery is
4.0.0 and EJS 3.1.10 in all three.
Five client-side bugs were fixed along the way, including two that broke real
flows: `app.api.delete` ignored the callback that `formAJAX` passes (so
DELETE-method forms — the proxy's host and DNS delete buttons — never refreshed),
and the login page threw on every logged-out visit while revealing its card.
No `setup.sh`, compose or config change: this is app-internal UI work. Verified
by driving a full stack of all three apps in a browser — every page renders
console-clean, nav gating is correct per role, and the OIDC login round trip
completes on both OIDC clients.
sso-manager-node 1.5.0:
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Fixed (sso-manager-node)
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
### Added (sso-manager-node)
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
proxy 1.4.0:
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- Admin-only nav items lost their inline `display: none` in favour of that class, and the brand link points at `/` instead of `#`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
jump-host 1.3.0:
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Added
- `.group-required { display: none }` in `public/css/styles.css`, the base rule the shared gating model reveals against.
- `#spa-shell` dropped its inline `margin-top`; `styles.css` already sets it and the shared shell adjusts it when a banner is shown.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.4.0] - 2026-07-25
### Bumped
- sso-manager-node -> [v1.4.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.4.0)
- proxy -> [v1.3.0](https://github.com/theta42/proxy/releases/tag/v1.3.0)
- jump-host -> [v1.2.0](https://github.com/theta42/jump-host/releases/tag/v1.2.0)
This release unifies the three theta42 apps onto shared `@simpleworkjs/*` packages
(`oidc-client`, `directory-schema`, `ldap`, `app-stack` — published under the
simpleworkjs org at 1.0.0), replacing each app's byte-identical forks of the same
code so they share one codebase and API schema. It also fixes a security
regression in the SSO directory discovery API (OAuth `client_secret_hash` leaked
to every authenticated caller) and the envelope drift that broke jump-host
bridging. The shared UI chrome (`top.ejs`/`bottom.ejs`, `app-base.js`, `val.js`)
is intentionally **not** unified in this release — that work is deferred to a
browser-verified session; see `UI_UNIFICATION_HANDOFF.md`. No `setup.sh` change:
the new `@simpleworkjs/*` deps resolve from npm inside each app's image build
(`npm ci` stays clean; no `file:`/`link:`).
sso-manager-node 1.4.0:
### Security
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
### Fixed
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404.
### Added
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
- `@simpleworkjs/ldap``models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
proxy 1.3.0:
### Added
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils.
- `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers.
### Security
- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=<username>))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping).
### Changed
- Dependency alignment: `model-redis` `^1.5``^1.6.0`, `ldapts` `^8.1.2``^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
jump-host 1.2.0:
### Added
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
- `@simpleworkjs/ldap``models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
### Fixed
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`, `redis` `^4.7``^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
- `app-base.js` `forceLogin`/`logInRedirect` switched to the `?redirect=` query-param convention (matching the server-side `/login?redirect=` route).
## [1.3.7] - 2026-07-23
### Added
- The bootstrap now provisions the jump host's **web-UI SSO login** when the jump host is enabled: it mints a dedicated `theta-jump` OAuth client and writes a full `oidc` block (endpoints, client id/secret, callback) plus a generated local anti-lockout admin password into `./config/jump-secrets.js`. Matches how the proxy's OIDC client is provisioned. An existing pre-OIDC `jump-secrets.js` (API token but no OIDC client) is regenerated so upgraders get SSO login. Requires jump-host ≥ v1.1.0.
## [1.3.6] - 2026-07-23
### Bumped
- sso-manager-node -> [v1.3.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.2)
sso-manager-node 1.3.2:
### Fixed
- **OAuth client management API returned `client_id: undefined` on every GET**, which broke this stack's bootstrap: it lists the OAuth clients and rotates by the returned `client_id`, so it called `/api/oauth/client/undefined/rotate` and got a 500 — aborting `setup.sh` with `bootstrap failed` whenever `proxy-secrets.js` had no usable secret (e.g. a fresh/rotated deployment). The ORM's `toJSON()` was stripping the mapped `client_id`/`scopes`/… fields; `OAuthClient.get()` now emits them explicitly (and omits `client_secret_hash`). Unknown client ids now 404 instead of 500.
## [1.3.5] - 2026-07-23
### Added
@@ -389,7 +1011,8 @@ First tagged release. Establishes the `vX.Y.Z` tag convention going forward.
- proxy -> [v1.1.0](https://github.com/theta42/proxy/releases/tag/v1.1.0)
- sso-manager-node -> [v1.1.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0)
[Unreleased]: https://github.com/theta42/theta-env/compare/v1.1.20...HEAD
[Unreleased]: https://github.com/theta42/theta-env/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/theta42/theta-env/compare/v1.3.7...v1.4.0
[1.1.17]: https://github.com/theta42/theta-env/compare/v1.1.16...v1.1.17
[1.1.16]: https://github.com/theta42/theta-env/compare/v1.1.15...v1.1.16
[1.1.15]: https://github.com/theta42/theta-env/compare/v1.1.14...v1.1.15
+12 -8
View File
@@ -26,14 +26,18 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
| --- | --- |
| [![SSO Manager dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
**Why use this instead of running the two separately?** The two only become
useful once the proxy is registered as an OIDC client of the SSO and pointed at
the SSO's LDAP directory — and the SSO's domain has to match across half a dozen
config fields or logins silently fail with `Invalid Credentials`. Doing that by
hand is fiddly and easy to get wrong. `setup.sh` asks for your domain once (in
`setup.env`), generates both config files with it filled in everywhere, registers
the proxy as an OIDC client, and snapshots state before every rebuild — so you
get a working SSO + proxy stack in one command and a safe way to upgrade it.
## Configuration
`setup.sh` automates the first-run glue between subprojects:
- Asks for your domain once (in `setup.env`) and fills it in across all config files.
- Registers the proxy as an OIDC client of the SSO.
- Persists submodule commit hashes in `.env` for reproducibility (e.g., `SSO_GIT_COMMIT`, `PROXY_GIT_COMMIT`). This ensures future `docker compose` runs use the same submodule versions.
**Why use this instead of running the two separately?** The two only become useful once the proxy is registered as an OIDC client of the SSO and pointed at the SSO's LDAP directory — and the SSO's domain has to match across half a dozen config fields or logins silently fail with `Invalid Credentials`. Doing that by hand is fiddly and easy to get wrong. `setup.sh` handles this automatically and snapshots state before every rebuild — so you get a working SSO + proxy stack in one command and a safe way to upgrade it.
## Unified Release Status
-**Phase 1 (oidc-client)**: Complete.
-**Phases 2-5**: Pending (see [roadmap](#)).
```
┌──────────────────────────────────────────────┐
+96 -30
View File
@@ -229,14 +229,15 @@ async function listClients(token) {
return (data && data.results) || [];
}
async function createClient(token) {
async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: CLIENT_NAME,
description: 'theta-env proxy (auto-registered)',
redirect_uris: [REDIRECT_URI],
name: o.name,
description: o.description,
redirect_uris: o.redirect_uris,
scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [],
}),
@@ -249,7 +250,7 @@ async function createClient(token) {
const id = (data.results && data.results.client_id) || data.client_id;
const secret = data.client_secret;
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
log(`Created OAuth client ${CLIENT_NAME} (${id})`);
log(`Created OAuth client ${o.name} (${id})`);
return { id, secret };
}
@@ -333,7 +334,7 @@ const HOST_FACTS = {
kernel: process.env.STACK_HOST_KERNEL || '',
};
async function seedDirectory(token, clientId) {
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Create a resource unless its slug (or a legacy alternate from an earlier
@@ -419,9 +420,10 @@ async function seedDirectory(token, clientId) {
});
// Optional SSH jump host service.
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}` : '');
await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
@@ -429,19 +431,21 @@ async function seedDirectory(token, clientId) {
});
}
// 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) {
const oauthRes = resources.find((r) => r.id === clientId);
if (oauthRes) {
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === clientId);
if (!linked) {
await dirPost(token, 'edges', { parentId: psvc.id, childId: clientId, relation: 'oauth' });
log(` directory: linked OAuth client under 'proxy'`);
}
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
await linkOauthClient(clientId, psvc, 'proxy');
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
@@ -487,6 +491,8 @@ 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';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
@@ -501,14 +507,19 @@ async function mintApiToken(token, name) {
return raw;
}
function jumpFileHasToken() {
// The generated file is "complete" only if it has BOTH a real directory API
// token AND an OIDC client id — an existing file from the pre-OIDC layout (a
// token but no oidc block) is regenerated so the web UI's SSO login works.
function jumpFileComplete() {
try {
const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
return /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src);
return hasToken && hasOidc;
} catch (_) { return false; }
}
function writeJumpSecrets(apiToken) {
function writeJumpSecrets(apiToken, oidc, localAdminPass) {
const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict';
@@ -519,7 +530,16 @@ function writeJumpSecrets(apiToken) {
module.exports = {
\tname: ${JSON.stringify(sso.name || 'SSO Manager')},
\tldap: {
\t\turl: 'ldap://sso-manager:389',
\t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
\t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
\t\t// treats a non-empty tlsOptions as "use implicit TLS" regardless of the
\t\t// URL scheme -- pointed at the plain port, that means it opens a raw TLS
\t\t// handshake against a server expecting plaintext LDAP, which slapd just
\t\t// drops (logged as "connection lost", no BIND ever attempted). This bit
\t\t// jump-host silently: every SSH login failed with the generic
\t\t// "Permission denied" for any password, because getUser()/checkPassword()
\t\t// never even reached slapd.
\t\turl: 'ldaps://sso-manager:636',
\t\tbindDN: ${JSON.stringify(BIND_DN)},
\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)},
\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)},
@@ -537,7 +557,27 @@ module.exports = {
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
\t},
\tweb: { port: 3002 },
\tauth: { adminGroups: ['app_sso_admin'] },
\t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint /
\t// userinfoEndpoint use the internal docker-net address (server-to-server);
\t// authorizationEndpoint is the public SSO host (browser-facing).
\toidc: {
\t\tenabled: true,
\t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)},
\t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)},
\t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token',
\t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
\t\tclientId: ${JSON.stringify(oidc.id)},
\t\tclientSecret: ${JSON.stringify(oidc.secret)},
\t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)},
\t\tscopes: ['openid', 'profile', 'email', 'groups'],
\t\tgroupsClaim: 'groups',
\t\tusernameClaim: 'preferred_username',
\t},
\tauth: {
\t\tadminGroups: ['app_sso_admin'],
\t\tadminUsers: ['jumpadmin'],
\t\tlocalAdminPass: ${JSON.stringify(localAdminPass)},
\t},
\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)} },
};
@@ -545,14 +585,39 @@ module.exports = {
fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 });
}
// Returns the jump host's OAuth client id (so seedDirectory can link it under
// the SSH Jump Host service), whether or not this run actually wrote a fresh
// jump-secrets.js -- otherwise re-runs on an already-configured deployment
// never get a chance to self-heal a missing directory link (see the "no
// parent" bug this was written for).
async function provisionJumpHost(token) {
if (jumpFileHasToken()) {
log('Jump host: /config/jump-secrets.js already has an API token — keeping.');
return;
if (jumpFileComplete()) {
log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
const clients = await listClients(token);
const existing = clients.find((c) => c.name === JUMP_CLIENT_NAME);
return existing ? existing.client_id : null;
}
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
writeJumpSecrets(apiToken);
log('Jump host: wrote /config/jump-secrets.js (minted directory API token).');
// Mint (or reuse) the jump host's own OAuth client for web-UI SSO login.
const clients = await listClients(token);
let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME);
if (oidc && oidc.client_id) {
oidc = await rotateClient(token, oidc.client_id);
oidc = { id: oidc.id, secret: oidc.secret };
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-env jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
const localAdminPass = crypto.randomBytes(16).toString('hex');
writeJumpSecrets(apiToken, oidc, localAdminPass);
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
return oidc.id;
}
(async function main() {
@@ -600,9 +665,10 @@ async function provisionJumpHost(token) {
// Provision the jump host (mint token + write config) when enabled.
// Warn-only — never fail the whole bring-up over the optional service.
let jumpClientId = null;
if (JUMP_ENABLED) {
try {
await provisionJumpHost(token);
jumpClientId = await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
@@ -613,7 +679,7 @@ async function provisionJumpHost(token) {
// fails the bootstrap — warn and continue.
try {
log('Seeding directory resources...');
await seedDirectory(token, resolvedClientId);
await seedDirectory(token, resolvedClientId, jumpClientId);
} catch (e) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
@@ -0,0 +1,26 @@
# ldap-client config for the optional local jump-host test fixture
# (ldap-test-host service in docker-compose.yml, jump-host compose profile).
# Copy to ./config/ldap-test-host.vars and fill in the bind password from
# your own ./config/sso-secrets.js's `serviceAccountPass` (the
# cn=ldapclient,ou=people,<base> service account bootstrap/bootstrap.js
# creates specifically for this kind of 3rd-party/container LDAP bind).
#
# This is what lets ldap-test-host be a REAL SSSD+AuthorizedKeysCommand-joined
# downstream host, so jump-host's key-injection -> upstream-connect flow can
# be exercised end-to-end against something more than a container with a
# manually-dropped public key in authorized_keys.
export ldap_host="sso-manager"
export ldap_base_dn="dc=localtest,dc=me"
export ldap_bind_dn="cn=ldapclient,ou=People,$ldap_base_dn"
export ldap_bind_password="REPLACE_WITH_serviceAccountPass_FROM_sso-secrets.js"
# sso_url/sso_token deliberately left unset -- register the host + access
# group manually via the Directory admin API instead (index.sh's optional
# auto-registration also wants a parent site Resource to exist first).
# index.sh gates that block on `[[ -v sso_token ]]`, which is true even for
# an empty string, so leave these genuinely absent, not "".
export ldap_location="jumptest"
ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )
+10
View File
@@ -30,6 +30,16 @@ module.exports = {
jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
},
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
// (relative to the app's /app cwd) -- inside the container's ephemeral
// layer, not any mounted volume, so every Resource/site/host/service/oauth
// row (the whole Directory Management page) would be silently wiped on
// every container recreate. /data is already a persisted volume (Redis
// lives there too), so this just co-locates the sqlite file with it.
orm: {
dialect: 'sqlite',
storage: '/data/inventory.sqlite',
},
// ── Orchestrator-only (ignored by the app; read by setup.sh + bootstrap) ──
stack: {
+79 -1
View File
@@ -36,8 +36,16 @@ services:
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C sso-manager-node rev-parse --short HEAD).
GIT_COMMIT: ${SSO_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build (NOT
# the theta42 "proxy" app). Set CFG_HTTP_PROXY in setup.env; empty by
# default, so this is a no-op unless configured.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: sso-manager
restart: unless-stopped
depends_on:
- openbao
networks: [theta-net]
ports:
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
@@ -58,6 +66,12 @@ services:
- NODE_PORT=3001
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
- VAULT_TOKEN=${VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
# runtime. See the build args above for the same setting during build.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
# can write the generated OAuth client creds into proxy-secrets.js. The
@@ -91,6 +105,11 @@ services:
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C proxy rev-parse --short HEAD).
GIT_COMMIT: ${PROXY_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: proxy
restart: unless-stopped
networks: [theta-net]
@@ -110,6 +129,11 @@ services:
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
- NODE_ENV=production
- NODE_PORT=3000
# Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's
# Encrypt, DNS providers) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
# only reads it; the sso-manager bootstrap writes the OAuth creds. The
@@ -142,6 +166,11 @@ services:
dockerfile: Dockerfile
args:
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: jump-host
restart: unless-stopped
networks: [theta-net]
@@ -153,10 +182,57 @@ services:
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
environment:
- NODE_ENV=production
# Optional upstream HTTP(S) proxy for outbound calls (the directory API
# client) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
- jump-data:/var/lib/jump-host # generated host keys persist here
- jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here
# A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for
# testing jump-host's actual key-injection -> upstream-connect flow --
# 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.
ldap-test-host:
profiles: ["jump-host"]
build:
context: ./ldap-client
dockerfile: Dockerfile
container_name: ldap-test-host
hostname: ldap-test-host
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
privileged: false
volumes:
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
openbao:
image: quay.io/openbao/openbao:latest
container_name: openbao
restart: unless-stopped
cap_add:
- IPC_LOCK
command: server -config=/vault/config/openbao.hcl
environment:
- BAO_ADDR=http://127.0.0.1:8200
ports:
- "8080:8200"
volumes:
- ./config/openbao.hcl:/vault/config/openbao.hcl:ro
- openbao-data:/vault/data
networks:
- theta-net
networks:
theta-net:
driver: bridge
@@ -168,4 +244,6 @@ volumes:
proxy-data:
proxy-cache:
proxy-logs:
jump-data:
jump-data:
jump-redis-data:
openbao-data:
+1 -1
View File
@@ -31,7 +31,7 @@ fetches all three in one step; `git submodule update --remote` bumps them.
```
┌──────────────────────────────────────────────┐
│ your browser / apps / legacy LDAP clients │
│ your browser / apps / direct LDAP clients │
└───────────────┬──────────────────────────────┘
│ https (:443) ldaps (:636)
┌─────────▼─────────┐
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+11 -2
View File
@@ -15,7 +15,9 @@ 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`.
generated from one `setup.env`. An optional third component, the
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
access to your machines through one public entry point.
## Screenshots
@@ -23,6 +25,7 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
<a href="images/sso-dashboard.png" target="_blank"><img src="images/sso-dashboard.png" alt="SSO Manager dashboard" width="49%"></a>
<a href="images/proxy-hosts.png" target="_blank"><img src="images/proxy-hosts.png" alt="Proxy host list" width="49%"></a>
<a href="images/jump-dashboard.png" target="_blank"><img src="images/jump-dashboard.png" alt="Jump Host dashboard" width="49%"></a>
*(click either screenshot to view full size)*
@@ -41,7 +44,11 @@ snapshots state before every rebuild.
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
and OAuth clients.
- **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for legacy apps that bind directly.
- **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.<domain>` (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`.
- **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.
@@ -67,3 +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`).
+3
View File
@@ -60,6 +60,9 @@ setups `CFG_DOMAIN` is the only value you set:
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | 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.<domain>` |
| `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
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets
Submodule
+1
Submodule ldap-client added at 31d8fa1229
+1 -1
Submodule proxy updated: 60dbfe5b9b...30835baaeb
+13
View File
@@ -49,6 +49,19 @@ CFG_DOMAIN=example.com
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
#CFG_BASE_DN=dc=example,dc=com
# ── 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.
# Wired into every service's docker build (npm/apt) AND its running container
# (SMTP, ACME/Let's Encrypt, DNS provider calls, the jump-host directory API
# client). Leave unset to disable (the default); CFG_HTTPS_PROXY falls back to
# CFG_HTTP_PROXY if unset, and CFG_NO_PROXY defaults to covering the stack's
# own internal service names so container-to-container traffic never goes
# through the proxy.
#CFG_HTTP_PROXY=http://proxy.example.com:3128
#CFG_HTTPS_PROXY=http://proxy.example.com:3128
#CFG_NO_PROXY=localhost,127.0.0.1,sso-manager,proxy,jump-host
# Optional — sensible defaults if left blank:
#CFG_ORG=SSO Manager # app display name + outbound email org
#CFG_ADMIN_UID=admin # initial SSO admin username
+110
View File
@@ -88,6 +88,23 @@ rand_hex() {
fi
}
# Upsert KEY=VALUE into ./.env, which `docker compose` auto-loads for every
# future invocation in this directory. Used to persist the *_GIT_COMMIT build
# args (see SSO_GIT_COMMIT/PROXY_GIT_COMMIT/JUMP_GIT_COMMIT below) so that an
# ad-hoc `docker compose up --build <service>` run later, OUTSIDE this script,
# still resolves the right commit instead of silently baking "unknown" (the
# submodule .git pointer file can't be resolved from inside the build
# context, so the value must come from the host via this file or the export).
env_upsert() {
local key="$1" val="$2" file=./.env
touch "$file"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
else
printf '%s=%s\n' "$key" "$val" >> "$file"
fi
}
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
@@ -165,6 +182,22 @@ 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
# ── 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
# environment) — NOT the theta42 "proxy" app; this is an upstream HTTP proxy
# for reaching the internet (npm/apt during image builds, and SMTP/ACME/DNS
# provider calls at runtime), useful on isolated/offline/corporate-network
# test hosts. Off by default. docker-compose.yml passes these through as both
# build args (Docker also recognizes them as predefined build ARGs) and
# container environment on every service, so one setup.env entry covers the
# whole stack.
export CFG_HTTP_PROXY="${CFG_HTTP_PROXY:-}"
export CFG_HTTPS_PROXY="${CFG_HTTPS_PROXY:-${CFG_HTTP_PROXY:-}}"
export CFG_NO_PROXY="${CFG_NO_PROXY:-localhost,127.0.0.1,sso-manager,proxy,jump-host}"
if [[ -n "$CFG_HTTP_PROXY" ]]; then
info "Using HTTP proxy for docker build + containers: $CFG_HTTP_PROXY"
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".
@@ -276,6 +309,16 @@ module.exports = {
jwtSecret: $(js_str "$CFG_JWT_SECRET"),
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
},
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
// relative to the app's /app cwd -- inside the container's ephemeral layer,
// not any mounted volume -- so every Resource/site/host/service/oauth row
// (the whole Directory Management page) would be silently wiped on every
// container recreate. /data is already a persisted volume (Redis lives
// there too), so this just co-locates the sqlite file with it.
orm: {
dialect: 'sqlite',
storage: '/data/inventory.sqlite',
},
// ── Orchestrator-only (ignored by the app) ───────────────────────────────
stack: {
@@ -349,6 +392,23 @@ PROXYEOF
}
ensure_config() {
if [[ ! -f "$CONFIG_DIR/openbao.hcl" ]]; then
info "Generating $CONFIG_DIR/openbao.hcl ..."
mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_DIR/openbao.hcl" <<BAOEOF
storage "file" {
path = "/vault/data"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
disable_mlock = true
ui = true
BAOEOF
chmod 644 "$CONFIG_DIR/openbao.hcl"
fi
if [[ -f "$CONFIG_DIR/sso-secrets.js" ]]; then
info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)."
return 0
@@ -458,6 +518,7 @@ ensure_config() {
mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
write_sso_secrets
write_proxy_secrets
chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js"
if [[ "$migrated" == "1" ]]; then
@@ -603,6 +664,45 @@ backup_before_rebuild() {
}
backup_before_rebuild
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
info "Starting openbao..."
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
"${COMPOSE[@]}" up -d openbao
info "Waiting for openbao to be reachable..."
for i in $(seq 1 30); do
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then
info "openbao is reachable."; break
fi
if (( i == 30 )); then die "openbao did not become reachable in 60s. Check: ${COMPOSE[*]} logs openbao"; fi
sleep 2
done
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true' || true; then
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if ! echo "$status_json" | grep -q '"initialized": true'; then
info "Initializing openbao for the first time..."
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
chmod 600 "$CONFIG_DIR/bao-init.json"
info "Openbao initialized. Keys saved to $CONFIG_DIR/bao-init.json"
fi
fi
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if echo "$status_json" | grep -q '"sealed": true'; then
info "Unsealing openbao..."
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
fi
export VAULT_TOKEN
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" | cut -d'"' -f4)
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
info "Enabling kv-v2 secrets engine at secret/..."
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets enable -path=secret kv-v2 >/dev/null
fi
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
# pointer file (not a real repo) -- the image can't resolve its own commit
@@ -611,6 +711,7 @@ backup_before_rebuild
# docker-compose.yml and sso-manager-node's Dockerfile.openldap.
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
export SSO_GIT_COMMIT
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build sso-manager
@@ -626,6 +727,13 @@ for i in $(seq 1 60); do
sleep 2
done
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf >/dev/null 2>&1; then
info "Seeding sso-manager/conf into Openbao..."
docker exec sso-manager node -e "console.log(JSON.stringify(require('/config/sso-secrets.js')))" > "$CONFIG_DIR/seed-conf.json"
cat "$CONFIG_DIR/seed-conf.json" | docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf -
rm -f "$CONFIG_DIR/seed-conf.json"
fi
# Read the summary values (hosts, admin, base DN) back from ./config via the
# running container's node — works whether ./config was generated or pre-existing.
read_config_kv() {
@@ -696,6 +804,7 @@ fi
# PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above.
PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)"
export PROXY_GIT_COMMIT
env_upsert PROXY_GIT_COMMIT "$PROXY_GIT_COMMIT"
info "Building + starting proxy (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build proxy
@@ -762,6 +871,7 @@ 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"
info "Building + starting jump-host (optional; enabled via CFG_JUMP_HOST_ENABLED)..."
"${COMPOSE[@]}" up -d --build jump-host
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
set -e
echo "=== Starting theta-env Integration Tests ==="
echo "=> Cleaning up any existing containers and volumes..."
docker-compose down -v
echo "=> Running setup.sh to initialize environment..."
# Run setup non-interactively if possible (we might need to export some env vars)
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-env to test integration between all the include projects".
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
# Actually, setup.sh initializes Vault. If we don't run it, Vault is sealed!
# Let's just write a curl test that checks if the containers start.
docker-compose up -d
echo "=> Waiting for services to become healthy..."
sleep 15 # Give time for containers to spin up
# Test proxy
echo "=> Testing Proxy..."
if ! curl -sS -o /dev/null -w "%{http_code}" http://localhost | grep -q "406"; then
echo "❌ Proxy failed to respond with 406 Not Acceptable on port 80 (default behavior)"
exit 1
fi
echo "✅ Proxy responds on port 80"
# Test SSO Manager Node
echo "=> Testing SSO Manager..."
if ! curl -sS -f -o /dev/null http://localhost:3001; then
echo "❌ SSO Manager failed to respond on port 3001"
exit 1
fi
echo "✅ SSO Manager responds on port 3001"
echo "=== All integration tests passed! ==="
docker-compose down -v
exit 0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
'use strict';
// Regression guard for bootstrap.js's generated jump-secrets.js template:
// its ldap block must use ldaps:// (implicit TLS, :636), never ldap:// (:389),
// as long as tlsOptions is set alongside it.
//
// ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL
// scheme, and jump-host's LDAP client always sets tlsOptions -- so ldap://
// + tlsOptions opens a raw TLS handshake against a port serving plaintext
// LDAP. The server silently drops the connection before any LDAP message
// parses, and every operation (getUser, checkPassword, ...) then fails
// identically -- indistinguishable from a wrong password. This shipped once
// (every SSH login to jump-host failed, for any account, any password) before
// being root-caused against a real deployment. Static, not a require()+exec
// of bootstrap.js, because bootstrap.js is a self-running provisioning script
// with real side effects (LDAP writes, API calls), not a library.
const fs = require('fs');
const path = require('path');
const BOOTSTRAP_PATH = path.join(__dirname, '..', 'bootstrap', 'bootstrap.js');
const src = fs.readFileSync(BOOTSTRAP_PATH, 'utf8');
// Isolate the generated jump-secrets.js template (the backtick string
// assigned to `body` inside writeJumpSecrets) rather than scanning the whole
// file, so this only ever looks at what's actually written to the deployed
// config -- not, say, a comment or an unrelated ldap:// URL elsewhere.
// bootstrap.js's own source has literal backslash-t escape sequences inside
// the backtick string (they only become real tabs when the template
// literal is actually evaluated) -- so these patterns match `\t` as two
// literal characters, not a real tab byte.
const bodyMatch = /const body = `([\s\S]*?)`;\n\tfs\.writeFileSync\(JUMP_SECRETS/.exec(src);
if (!bodyMatch) {
console.error('check_jump_ldap_tls: could not locate the jump-secrets.js template in bootstrap.js — did writeJumpSecrets change shape?');
process.exit(1);
}
const template = bodyMatch[1];
// Bounded by the next top-level key (sso:) rather than the ldap block's own
// closing brace, which is more robust to exactly how it's indented/escaped.
const ldapBlockMatch = /ldap:\s*\{([\s\S]*?)\\tsso:\s*\{/.exec(template);
if (!ldapBlockMatch) {
console.error('check_jump_ldap_tls: could not find the ldap: {...} block in the jump-secrets.js template.');
process.exit(1);
}
const ldapBlock = ldapBlockMatch[1];
const hasTlsOptions = /tlsOptions\s*:/.test(ldapBlock);
const urlMatch = /url:\s*'([^']+)'/.exec(ldapBlock);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
console.error('check_jump_ldap_tls: no url found in the ldap block.');
process.exit(1);
}
if (hasTlsOptions && !url.startsWith('ldaps://')) {
console.error(
`check_jump_ldap_tls: jump-secrets.js template sets tlsOptions but url is "${url}" (not ldaps://). ` +
'This is the exact bug that broke every SSH login to jump-host -- see the comment above this check.'
);
process.exit(1);
}
console.log(`check_jump_ldap_tls: OK (url=${url}, tlsOptions=${hasTlsOptions})`);