Compare commits

..

129 Commits

Author SHA1 Message Date
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
wmantly c83248e40b Merge pull request #86 from theta42/release/v1.3.5
Release 1.3.5
2026-07-23 15:58:09 -04:00
wmantly e5a5eef428 Release 1.3.5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:57:50 -04:00
wmantly d098ba7082 Merge pull request #85 from theta42/feature/jump-host
Optional SSH jump host component (theta42/jump-host)
2026-07-23 15:57:34 -04:00
wmantly 1f8f4c70be feat: optional SSH jump host component (theta42/jump-host)
Adds jump-host as a third, opt-in submodule, wired behind
CFG_JUMP_HOST_ENABLED (default off — existing installs unaffected):

- .gitmodules + jump-host submodule pinned to v1.0.0
- setup.sh: resolves the enable flag early, adds jump-host to the
  submodule tag-update loop and activates the `jump-host` compose
  profile when enabled; builds/starts the service after the proxy,
  waits for its /health, and registers its web UI as a proxy Host;
  passes CFG_JUMP_HOST_ENABLED/CFG_JUMP_HOST to the bootstrap
- docker-compose.yml: jump-host service with profiles:["jump-host"],
  depends_on sso-manager healthy, ports 2222 (SSH) + 3002 (web),
  ./config:ro + jump-data volume
- bootstrap.js: when enabled, mints a directory API token and writes
  ./config/jump-secrets.js (binds as cn=admin so it can write the
  sshPublicKey attribute for key injection), and seeds a directory
  service entry for the jump host. Warn-only, idempotent.
- setup.env.example: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:55:40 -04:00
wmantly 9671339076 Merge pull request #84 from theta42/release/v1.3.4
Release 1.3.4: bump sso-manager-node to v1.3.1
2026-07-23 03:31:14 -04:00
wmantly aa74ca1b8c Release 1.3.4: bump sso-manager-node to v1.3.1
Directory documentation surfaced in-app; direct-LDAP-binds reframing.
theta-env's own directory seeding (site/host/services + facts, ports,
repos) ships in this release — see CHANGELOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:30:33 -04:00
wmantly cf919de1a6 Merge pull request #83 from theta42/feature/seed-facts
Site name config, host facts, and service ports/repos in the directory seed
2026-07-23 03:20:56 -04:00
wmantly 3e85e37b63 feat: site name config, host facts, and service ports/repos in the directory seed
- CFG_SITE_NAME in setup.env (below CFG_DOMAIN, default "local") names
  the directory site; slug site_<name> matches ldap-client's parentSlug
  convention so joined Linux hosts land under the same site. Wired
  through sso-secrets.js stack.siteName.
- setup.sh collects host facts ON THE HOST (hostname, IP, default-route
  MAC, OS pretty-name, kernel — same collection as ldap-client/index.sh)
  and passes them into the bootstrap exec env; the stack host is now
  registered as host_<hostname> with that metadata (subType linux).
- Services carry their internal port and git repo in metadata
  (sso-manager 3001, proxy 3000, openldap 389/ext 636, openresty 443),
  using the metadata keys the directory UI natively displays.
- ensure() now adopts resources from the earlier seed layout (alt slugs
  'stack-host' / domain-slug site) and back-fills missing seed metadata
  via a metadata-only PUT — operator-set values are never overwritten.

Verified against a live app: old-layout resources are adopted and
back-filled (no duplicates), fresh seed creates the full graph, and a
second pass changes nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:18:20 -04:00
wmantly 75278f3e16 Merge pull request #82 from theta42/docs/ldap-not-legacy
docs: direct LDAP binds are first-class, not "legacy"
2026-07-23 02:56:35 -04:00
wmantly faf67d8ffc docs: direct LDAP binds are first-class, not "legacy"
Linux hosts authenticate against the directory (PAM/SSSD, sudoRole,
sshPublicKey) — reframe the OpenLDAP seed comment and changelog entry
accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:56:17 -04:00
wmantly 892069eaea Merge pull request #81 from theta42/feature/seed-ldap-openresty
Also seed OpenLDAP and OpenResty services in the directory
2026-07-23 02:44:39 -04:00
wmantly 4f61eeb1a7 feat: also seed OpenLDAP and OpenResty services in the directory
The stack runs two more real services than the last seed captured:

- OpenLDAP: independently consumed via direct LDAPS binds (the SSO's
  /integrations page advertises it). Seeded with the ldaps:// endpoint,
  honoring ldap.ldapsHost when the operator set one.
- OpenResty: the proxy container's data plane (80/443) that every
  hostname in the stack actually flows through — distinct from the
  'proxy' entry, which is the node management UI. Seeded with a
  wildcard https://*.<domain> address (same wildcard convention the
  proxy's Host records use).

Both use metadata.subType so the directory UI badges them as
service (openldap) / service (openresty). Same idempotency: existing
slugs are operator-owned and untouched. Re-verified against a live app:
pass 1 creates all six resources + oauth edge, pass 2 changes nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:44:19 -04:00
wmantly f51b7e2dbe Merge pull request #80 from theta42/feature/seed-directory
Seed the SSO directory with the stack's own resources at bootstrap
2026-07-23 02:40:18 -04:00
wmantly 1c96c75118 feat: seed the SSO directory with the stack's own resources at bootstrap
The Directory page started empty even though setup.sh knows exactly what
it deployed. The bootstrap now seeds (via /api/directory-admin, as the
logged-in admin): a site from the configured domain, a "Stack host", the
SSO Manager + Proxy services with their public URLs in metadata, and
links the proxy's auto-registered OAuth client under its service.

Idempotent: resources whose slug already exists are operator-owned and
never touched. A seed failure only warns — never fails a bring-up (e.g.
against an older sso-manager image without /api/directory-admin).

Verified against a live app (sso-manager-node test stack): first pass
creates site/host/2 services + oauth edge; second pass changes nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:39:41 -04:00
wmantly 5c8e5be0c5 Merge pull request #79 from theta42/release/v1.3.3
Release 1.3.3: bump sso-manager-node to v1.3.0
2026-07-23 02:29:30 -04:00
wmantly 4b613c7ca1 Release 1.3.3: bump sso-manager-node to v1.3.0
Completes the ORM port (OTP/impersonation 500s, OAuth authorize 400s),
adds the OAuth client management API and dockerized test suite. See
CHANGELOG.md for the embedded submodule changelogs (v1.2.1 + v1.3.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:28:50 -04:00
wmantly a118f7ad4e Merge pull request #78 from theta42/release/v1.3.2
Release v1.3.2: bump proxy to v1.2.2 (fixes load-balancing crash)
2026-07-21 16:24:27 -04:00
wmantly 87c8c0fc02 Release 1.3.2: bump proxy to v1.2.2 (fixes load-balancing crash)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 16:23:54 -04:00
wmantly 43cdf1dbbb Merge pull request #77 from theta42/release/v1.3.1
Release v1.3.1: bump proxy to v1.2.1, sso-manager-node to v1.1.18
2026-07-21 02:26:20 -04:00
wmantly 9a737dd178 Point submodules at the actual merged release commits
The release commits for proxy v1.2.1 and sso-manager-node v1.1.18
landed as merge commits when their PRs were merged, not the commits
originally pinned here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:25:39 -04:00
wmantly fea1237c46 Release 1.3.1: bump proxy to v1.2.1, sso-manager-node to v1.1.18
Both bumps are bug-fix releases: proxy fixes a bootstrap admin lockout
bug, sso-manager-node fixes a crash on the new Sites & Replication
page. See CHANGELOG.md for the embedded submodule changelogs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:16:08 -04:00
wmantly 0b55535aa9 Merge pull request #76 from theta42/docs/update-proxy-load-balancing
chore: Update proxy submodule and docs for load balancing
2026-07-21 01:00:42 -04:00
wmantly 2baf8acd64 chore: Update proxy submodule and docs for load balancing 2026-07-21 01:00:26 -04:00
wmantly 3943ed02c5 Merge pull request #75 from theta42/chore/bump-sso-manager-node-v1.2.0
chore: bump sso-manager-node to v1.2.0
2026-07-21 00:18:27 -04:00
wmantly 7f43eee36e chore: bump sso-manager-node to v1.2.0 2026-07-21 00:17:52 -04:00
wmantly 19ea7e012a Merge pull request #74 from theta42/feature/multi-master-ldap-config
feat: Add LDAP replication configuration options
2026-07-21 00:14:25 -04:00
wmantly 94b357e915 docs: Add Multi-Site Support to features list 2026-07-21 00:10:26 -04:00
wmantly f5d8cdd09d feat: add LDAP replication configuration options 2026-07-20 23:56:16 -04:00
wmantly 96b3aec5eb Merge pull request #73 from theta42/release-1.1.20
Release 1.1.20: bump proxy submodule to v1.1.17
2026-07-20 00:31:55 -04:00
wmantly 5aff5349a8 Release 1.1.20: bump proxy submodule to v1.1.17
Pins the proxy submodule to v1.1.17 (wildcard sibling-parent fix: an
existing single-label subdomain host can now be attached to a wildcard
cert added afterward). Embeds proxy's v1.1.17 changelog in the theta-env
release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:31:12 -04:00
wmantly 3b0f8f1f9a chore: bump sso-manager-node submodule to v1.1.17 (#72)
Picks up the configurable LDAPS hostname feature and docs.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:14:32 -04:00
wmantly 28016376ad feat: propagate CFG_LDAPS_HOST through setup and document LDAPS networking (#71)
Pass optional CFG_LDAPS_HOST from setup.env through setup.sh into the
generated ./config/sso-secrets.js as ldap.ldapsHost. This lets operators
advertise an internal-only LDAPS hostname (e.g. ldap.internal.example.com
or sso-manager) on the SSO /integrations page instead of the public
OAuth issuer, avoiding a public 636 port forward.

- setup.env.example: add CFG_LDAPS_HOST
- setup.sh: read/forward CFG_LDAPS_HOST into sso-secrets.js
- config.example/sso-secrets.js.example: document ldapsHost/ldapsPort
- .env.example: add LDAPS_HOST for legacy .env migrations
- docker-compose.yml: comment warning against public 636 forwarding
- README.md: explain CFG_LDAPS_HOST recommendation
- CHANGELOG.md + bump version to 1.1.19

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:14:03 -04:00
wmantly daa48dd154 chore(release): public-release readiness fixes for 1.1.18
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:22:35 -04:00
wmantly a226ff9d00 chore: pin proxy and sso-manager-node submodules to v1.1.16
- proxy: 289a9587d62facf67876efc85472f01576e1d6d2
- sso-manager-node: 5a8030fd7d95edf83618b74ae05d790a62ccf940

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:22:13 -04:00
wmantly 65a4c1d839 fix: remove unused password variables from setup.sh summary
- After the setup summary stopped printing generated passwords,
  ADMIN_PASS and PROXY_LOCAL_ADMIN_PASS were assigned but never used,
  causing shellcheck SC2034 warnings in CI. Drop them from the summary.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:14:08 -04:00
wmantly 005c66d3f4 docs: update changelog for v1.1.18 submodule notes
- Add XSS/PII-logging security notes for proxy and sso-manager-node v1.1.16.
- Fix comparison links to point to v1.1.18/v1.1.16.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:08:32 -04:00
wmantly e46768bb68 chore(release): public-release readiness fixes for 1.1.18
- CHANGELOG.md now embeds full app-level release notes for submodule bumps.
- .env.example uses explicit CHANGE-ME placeholders instead of realistic-looking defaults.
- config.example comments describe the actual CONF_SECRETS mechanism.
- setup.sh summary no longer prints generated passwords to stdout.
- bootstrap.js fails hard instead of falling back to weak default passwords.

Note: submodule pins will be updated to v1.1.16 after the app PRs merge.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:11:13 -04:00
wmantly 8def1f6340 Merge pull request #69 from theta42/bump-both-1.1.15
Bump proxy and sso-manager-node submodule pins to v1.1.15
2026-07-18 01:23:30 -04:00
wmantly 49cd134fb3 Bump proxy and sso-manager-node submodule pins to v1.1.15
- proxy -> v1.1.15
- sso-manager-node -> v1.1.15

Both apps' bare-metal install.sh now installs to /opt/theta42/<app>
and seeds /etc/<app>/secrets.js on first run.

Also: setup.sh now prints the version each submodule is updating
from/to (or "already up to date") when re-run, instead of only
printing on an actual change with commit hashes -- and the
self-update step shows theta-env's own tag, not just a hash, when
one resolves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:22:59 -04:00
wmantly 30146e9588 Merge pull request #68 from theta42/bump-both-1.1.14
Bump proxy and sso-manager-node submodule pins to v1.1.14
2026-07-17 23:49:46 -04:00
wmantly 481602ae60 Bump proxy and sso-manager-node submodule pins to v1.1.14
- proxy -> v1.1.14
- sso-manager-node -> v1.1.14

Both bump @simpleworkjs/conf to 1.2.0 and jq-repeat to 2.2.0, and use
the new CONF_SECRETS env var instead of symlinking the mounted secrets
file into /app/conf/secrets.js. Updated theta-env's own docs/setup.sh/
docker-compose.yml comments to match -- no change to the config file
format or bind mounts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:49:21 -04:00
wmantly 26b853e1d6 Merge pull request #67 from theta42/bump-both-1.1.13
Bump proxy and sso-manager-node submodule pins to v1.1.13
2026-07-17 22:33:31 -04:00
wmantly 04a9c557e8 Bump proxy and sso-manager-node submodule pins to v1.1.13; update CHANGELOG
- proxy -> v1.1.13
- sso-manager-node -> v1.1.13

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:33:05 -04:00
wmantly d1136a98a6 Merge pull request #66 from theta42/bump-both-1.1.11
Bump proxy and sso-manager-node submodule pins to v1.1.11
2026-07-17 20:03:42 -04:00
wmantly 33f603dcc0 Bump proxy and sso-manager-node submodule pins to v1.1.11; update CHANGELOG
- proxy -> v1.1.11
- sso-manager-node -> v1.1.11

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 20:03:01 -04:00
wmantly f3a5194104 Merge pull request #65 from theta42/bump-both-1.1.10
Bump proxy and sso-manager-node submodule pins to v1.1.10
2026-07-17 19:31:07 -04:00
wmantly d21dfefc37 Bump proxy and sso-manager-node submodule pins to v1.1.10; update CHANGELOG
- proxy -> v1.1.10
- sso-manager-node -> v1.1.10

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:30:29 -04:00
wmantly 0861b6a075 Merge pull request #64 from theta42/bump-proxy-1.1.9
Bump proxy submodule pin to v1.1.9
2026-07-17 19:06:42 -04:00
wmantly fee1baaaf9 Bump proxy submodule pin to v1.1.9; update CHANGELOG
- proxy -> v1.1.9

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:06:07 -04:00
wmantly 5f5df7d2bf Merge pull request #63 from theta42/bump-proxy-1.1.8
Bump proxy submodule pin to v1.1.8
2026-07-17 18:48:56 -04:00
wmantly 03838e46d9 Bump proxy submodule pin to v1.1.8; update CHANGELOG
- proxy -> v1.1.8

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 18:48:23 -04:00
wmantly a8373fa413 Merge pull request #62 from theta42/bump-sso-1.1.9
Bump sso-manager-node submodule pin to v1.1.9
2026-07-17 11:33:00 -04:00
wmantly 40e4c9f9ef Bump sso-manager-node submodule pin to v1.1.9; update CHANGELOG
- sso-manager-node -> v1.1.9

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:32:05 -04:00
wmantly 3bece9beeb Merge pull request #61 from theta42/bump-sso-1.1.8
Bump sso-manager-node submodule pin to v1.1.8
2026-07-17 11:05:10 -04:00
wmantly f5312fa528 Bump sso-manager-node submodule pin to v1.1.8; update CHANGELOG
- sso-manager-node -> v1.1.8

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:04:11 -04:00
wmantly 765664aa51 Merge pull request #60 from theta42/bump-sso-1.1.7
Bump sso-manager-node submodule pin to v1.1.7
2026-07-17 00:41:41 -04:00
wmantly b4055d3579 Bump sso-manager-node submodule pin to v1.1.7; update CHANGELOG
- sso-manager-node -> v1.1.7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 00:41:16 -04:00
wmantly 5a9767e9e8 Merge pull request #59 from theta42/bump-1.1.7
Bump submodule pins to v1.1.7/v1.1.6; backfill CHANGELOG.md
2026-07-16 20:50:25 -04:00
wmantly 8bc6e9c0e0 Bump submodule pins to v1.1.7/v1.1.6; backfill CHANGELOG.md
CHANGELOG.md had fallen behind theta-env's own tags -- entries
stopped at 1.1.3 despite v1.1.4/v1.1.5/v1.1.6 already being tagged
and released (each with real notes on GitHub, just never copied back
into this file). Backfilled 1.1.4-1.1.6 from the actual release
notes, then added 1.1.7 for this bump + the docs site redesign.

- proxy -> v1.1.7
- sso-manager-node -> v1.1.6
2026-07-16 20:49:59 -04:00
wmantly dbae26f095 Merge pull request #58 from theta42/redesign-docs-site
Redesign docs site: match the projects' own look, add SEO, mobile-ready
2026-07-16 20:21:05 -04:00
wmantly 502aba3da6 Redesign docs site: match the projects' own look, add SEO, mobile-ready
Same treatment as the proxy and sso-manager-node companion PRs. This
repo has no app UI of its own (it's a bash orchestrator), so both the
nav logo and favicon use the shared theta42.svg mark -- matching the
family look shown in its own screenshots (the SSO Manager/proxy
dashboards it stands up).

- New cross-page nav (Home/Quickstart/Architecture/Standalone/
  Changelog) -- replaces index.md's old "More docs" section, now
  redundant with the top nav.
- SEO: jekyll-seo-tag + jekyll-sitemap, per-page meta description,
  OG/Twitter card tags, canonical URLs, JSON-LD, sitemap.xml,
  robots.txt.
- Mobile: Bootstrap's responsive grid + collapsible navbar; the
  screenshot pair in index.md stacks to full-width below 576px.
- Added docs/_site to .gitignore (missing entirely before -- the
  other two repos already had it).

Verified with a real Jekyll build (jekyll/jekyll Docker image) +
Playwright: desktop and mobile (375px) screenshots, mobile nav
toggle, active-link highlighting, zero console/page errors, and
confirmed real SEO output (meta description, OG/Twitter tags,
canonical, JSON-LD, sitemap.xml, robots.txt) via curl against the
served site.
2026-07-16 20:20:42 -04:00
wmantly c44527984d Merge pull request #57 from theta42/bump-1.1.6
Bump proxy submodule pin to v1.1.6
2026-07-16 19:05:31 -04:00
wmantly f2ede4c018 Bump proxy submodule pin to v1.1.6
Picks up the Authentication tab radio-exclusivity fix.
2026-07-16 19:05:08 -04:00
wmantly c4fb8b8a7c Merge pull request #56 from theta42/bump-1.1.5
Bump proxy and sso-manager-node submodule pins to v1.1.5
2026-07-16 18:39:47 -04:00
wmantly 56ba01ad18 Bump proxy and sso-manager-node submodule pins to v1.1.5
Picks up the jq-repeat 2.1.0 upgrade in both apps.
2026-07-16 18:39:25 -04:00
wmantly 9c10b3b1d1 Merge pull request #55 from theta42/bump-1.1.4
Bump proxy and sso-manager-node submodule pins to v1.1.4
2026-07-16 17:48:36 -04:00
wmantly 7a65841d2d Bump proxy and sso-manager-node submodule pins to v1.1.4
Picks up: unified master-branch protection backed by real CI on all
3 repos, a ppolicy pwdLockout fix in sso-manager-node, and
white-label support (conf-driven title/logo) in both apps.
2026-07-16 17:48:13 -04:00
wmantly 7fd2b828ab Merge pull request #54 from theta42/add-ci
Add CI: shellcheck setup.sh, syntax-check bootstrap.js
2026-07-16 17:03:12 -04:00
wmantly c7b90b7e7c setup.sh: fix shellcheck findings (SC2115, SC2155 x2, SC2034)
- rm -rf "$BACKUP_DIR/$old" -> "${BACKUP_DIR:?}/$old": if BACKUP_DIR
  ever ended up empty, this was rm -rf /$old. Low practical risk
  (BACKUP_DIR is a hardcoded ./backups default), but cheap to harden.
- export FOO="$(...)" split into assign-then-export so a failing
  command substitution isn't masked by export's own exit status.
- Removed CLIENT_SECRET=$(getval CLIENT_SECRET): extracted from
  bootstrap's output but never used afterward (already written
  directly into proxy-secrets.js by bootstrap.js itself).
2026-07-16 17:02:29 -04:00
wmantly a6653cfb96 Add CI: shellcheck setup.sh, syntax-check bootstrap.js
theta-env has no app code of its own to unit-test (it orchestrates
the proxy/sso-manager-node submodules) -- this catches the one thing
that can actually break silently: setup.sh and bootstrap.js.
2026-07-16 17:00:27 -04:00
wmantly 9cf2d70226 Merge pull request #53 from theta42/add-changelog-bump-1.1.3
Add CHANGELOG.md; bump submodules to v1.1.3
2026-07-16 16:08:29 -04:00
wmantly e5f3e1714f Add CHANGELOG.md; bump submodules to v1.1.3 (closes #43)
Adds a Keep-a-Changelog-style CHANGELOG.md, linked from README and
docs/index.md, closing the "no changelog or versioning scheme"
issue. Bumps proxy and sso-manager-node to v1.1.3 (both add their
own CHANGELOG.md, served in-app at /docs/changelog).
2026-07-16 16:08:17 -04:00
wmantly 4fe1c41b82 Merge pull request #52 from theta42/docs-nav-and-bump-1.1.2
docs: link Quickstart/Architecture/Standalone from Home; bump submodules to v1.1.2
2026-07-16 15:40:41 -04:00
wmantly c60e745665 docs: link Quickstart/Architecture/Standalone from Home; bump submodules to v1.1.2
docs/index.md (the published site's home page) never linked to
architecture.md, quickstart.md, or standalone.md -- they were only
reachable by direct URL. Added a "More docs" section linking all
three.

Bumps proxy and sso-manager-node to v1.1.2 (air-gap fixes + in-app
/docs on both).
2026-07-16 15:40:25 -04:00
wmantly 8a6edd4f0e Merge pull request #51 from theta42/track-release-tags
setup.sh: pin submodules to their latest release tag, not master's tip
2026-07-16 14:02:03 -04:00
wmantly c47aa209be setup.sh: pin submodules to their latest release tag, not master's tip
Both proxy and sso-manager-node now publish real vX.Y.Z tags (see
their own release history). Track those instead of following the
branch tip with `git submodule update --remote`, so a rebuild always
lands on a tagged, versioned release rather than whatever commit
happened to be most recently merged upstream.

Bumps the submodule pins to their current latest tags as a result:
proxy -> v1.1.1, sso-manager-node -> v1.1.1.
2026-07-16 14:01:41 -04:00
wmantly a56a594ff5 Merge pull request #50 from theta42/bump-sso-editable-tos
Bump sso-manager-node submodule pin (editable ToS)
2026-07-16 13:46:05 -04:00
wmantly 287f821e8c Bump sso-manager-node submodule pin
Picks up runtime-editable Terms of Service (closes theta42/sso-manager-node#39).
2026-07-16 13:45:53 -04:00
wmantly 41443f4be9 Merge pull request #49 from theta42/bump-proxy-duckdns-fix
Bump proxy submodule pin (DuckDNS validation fix)
2026-07-16 12:57:28 -04:00
wmantly 9fae4b3b75 Bump proxy submodule pin
Picks up the DuckDNS provider fix: adding a provider no longer pushes
this host's public IP to the domain's A/AAAA record as a side effect
of token validation.
2026-07-16 12:56:46 -04:00
wmantly 273577d124 Merge pull request #48 from theta42/bump-1.1.0-submodules
Bump proxy and sso-manager-node submodule pins to 1.1.0
2026-07-15 22:43:11 -04:00
wmantly b295c3ae7f Bump proxy and sso-manager-node submodule pins to 1.1.0 2026-07-15 22:42:58 -04:00
wmantly c7276b1e14 Merge pull request #47 from theta42/bump-backup-and-update-check
Bump proxy and sso-manager-node submodule pins
2026-07-15 22:36:41 -04:00
wmantly 7c3f275b71 Bump proxy and sso-manager-node submodule pins
Picks up the standalone backup scripts (ops/backup.sh) and admin
update-check banner in both repos.
2026-07-15 22:36:21 -04:00
wmantly f5c6097924 Merge pull request #46 from theta42/setup-submodule-update-notice
setup.sh: report which submodules actually moved on update
2026-07-15 22:35:34 -04:00
wmantly 49ae0f9b96 setup.sh: report which submodules actually moved on update
git submodule update --init --remote was silent about whether anything
changed. Record each submodule's pinned commit before pulling and print
a before -> after notice for any that moved, so operators running
setup.sh get a clear signal of what was actually updated.
2026-07-15 22:34:54 -04:00
wmantly 3bff9af42d Merge pull request #40 from theta42/bump-sso-unix-service-accounts
Bump sso-manager-node: Unix service accounts, deployment docs de-dup
2026-07-15 20:55:32 -04:00
wmantly 83750ff609 Bump sso-manager-node: Unix service accounts, deployment docs de-dup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:54:31 -04:00
wmantly 1730a5036c Merge pull request #39 from theta42/bump-sso-integrations-page
Bump sso-manager-node: merged Integrations page, Service Accounts, LDAPS docs
2026-07-15 19:58:55 -04:00
wmantly 6b8cd7e553 Bump sso-manager-node: merged Integrations page, Service Accounts, LDAPS docs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:58:26 -04:00
wmantly 4117d7a8ff Merge pull request #38 from theta42/bump-sso-ldap-info-page
Bump sso-manager-node: LDAP Info page
2026-07-15 19:36:03 -04:00
wmantly 62ce314be2 Bump sso-manager-node: LDAP Info page
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:35:18 -04:00
wmantly abdc2c0c2e Merge pull request #37 from theta42/bump-sso-ldap-docs
Bump sso-manager-node: 3rd-party LDAP integration docs
2026-07-15 17:27:43 -04:00
wmantly df5e5e1f56 Bump sso-manager-node: 3rd-party LDAP integration docs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:27:20 -04:00
wmantly 1fab34b6ec Merge pull request #36 from theta42/bump-sso-notification-safety
Bump sso-manager-node: notification Compose safety fixes
2026-07-15 17:04:54 -04:00
wmantly cc8e83f42f Bump sso-manager-node: notification Compose safety fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:03:46 -04:00
wmantly af68c73629 Merge pull request #35 from theta42/bump-submodules-marketing-pages
Bump proxy and sso-manager-node: marketing landing pages
2026-07-15 16:11:19 -04:00
wmantly 69e02cc121 Bump proxy and sso-manager-node: marketing landing pages
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:10:55 -04:00
wmantly ee79b3888b Merge pull request #34 from theta42/marketing-pages
Turn GitHub Pages into a marketing landing page
2026-07-15 16:09:50 -04:00
wmantly fb2d1486c1 Turn GitHub Pages into a marketing landing page; cross-link, drop download buttons
- Rewrite docs/index.md as a short landing page (what it is, screenshots,
  why this over running the two separately, what you get, a minimal
  "get it" snippet) instead of a full config/architecture reference --
  that content still lives in the repo (README, docs/*.md), linked from
  here.
- Cross-link to SSO Manager's and Proxy's own Pages sites.
- Screenshots are now clickable (open full size) on both the Pages site
  and the README.
- Disable show_downloads in docs/_config.yml -- the Cayman theme's
  "Download .zip/.tar.gz" buttons are gone; "View on GitHub" (which links
  back to the repo) is the only header link now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:08:53 -04:00
wmantly f7ae7c5ec7 Merge pull request #33 from theta42/bump-submodules-docs
Bump proxy and sso-manager-node: README/docs screenshots
2026-07-15 15:47:32 -04:00
wmantly a9f9ff00ab Bump proxy and sso-manager-node: README/docs screenshots
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:46:01 -04:00
wmantly 6b7488cb0a Merge pull request #32 from theta42/docs-screenshots
Add Documentation link + screenshots to README and docs site
2026-07-15 15:44:58 -04:00
wmantly 7e679928ce Add Documentation link + screenshots to README and docs site
theta-env's GitHub Pages site (docs/, Jekyll) was already configured and
live at https://theta42.github.io/theta-env/ but nothing in the README
linked to it, unlike proxy and sso-manager-node's READMEs -- easy to miss
entirely. Add the same top-of-README Documentation link, plus screenshots
of the composed stack (SSO dashboard + proxy host list from one
./setup.sh run). Also fixes a stale docs/index.md quickstart snippet that
said "set CFG_BASE_DN to your domain" -- CFG_DOMAIN is the actual
required variable (CFG_BASE_DN is an advanced override); everywhere else
in the docs already says CFG_DOMAIN correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:42:19 -04:00
wmantly 1bf3f63619 Merge pull request #31 from theta42/bump-sso-remove-services-card
Bump sso-manager-node: remove hardcoded Services card
2026-07-15 01:00:37 -04:00
wmantly 192c5a3c6e Bump sso-manager-node: remove hardcoded Services card
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 01:00:12 -04:00
wmantly bc0f7ae6a1 Merge pull request #30 from theta42/bump-submodules-auth-oauth-perf
Bump proxy and sso-manager-node: auth exclusivity, OAuth wildcard redirect_uri, perf fixes
2026-07-15 00:49:33 -04:00
wmantly 32684d5d35 Bump proxy and sso-manager-node: mutually-exclusive host auth, OAuth wildcard redirect_uri, and performance fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:48:59 -04:00
wmantly a61ad10262 Merge pull request #29 from theta42/bump-submodules-nav-unification
Bump proxy and sso-manager-node: unified nav bars
2026-07-14 23:51:04 -04:00
wmantly fc9528f4ba Bump proxy and sso-manager-node: unified nav bars
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:50:44 -04:00
wmantly 57040bd62a Bump sso-manager-node submodule to pick up the uid/gidNumber fix (#28)
sso-manager-node: 38cc669 -> f45349e (#44) — fixes a crash that broke
every user creation on a theta-env-bootstrapped install
(InvalidSyntaxError on gidNumber), and adds a configurable id floor so
real users start at uidNumber/gidNumber 1500 instead of colliding with
or following the bootstrap admin's reserved 10000.
2026-07-14 23:06:13 -04:00
wmantly 189980d862 Bump proxy + sso-manager-node submodules to latest master (#27)
proxy: 22f382b -> c68fcc9 (#133) — sticky footer fix, Docker commit
hash baked in (incl. submodule GIT_COMMIT build-arg support),
configurable local admin password.
sso-manager-node: f72e888 -> 38cc669 (#43) — Docker commit hash baked
in (incl. submodule GIT_COMMIT build-arg support).

Together with the already-merged theta-env#26, a fresh ./setup.sh run
now generates + prints the proxy's local admin password and bakes
real commit hashes into both images instead of "unknown".
2026-07-14 22:34:25 -04:00
wmantly 9ca3b1a113 Generate the proxy's local admin password, and bake real commit hashes (#26)
Two related fixes found while testing the Docker build:

1. Print the proxy's local anti-lockout admin (proxyadmin2) password
   in the summary. Previously this account was always created with
   username == password == "proxyadmin2" (a hardcoded proxy default —
   see theta42/proxy#133), and setup.sh had no way to know or surface
   whatever password ended up in use. Now generates a random
   CFG_PROXY_ADMIN_PASS the same way it already does for the SSO
   admin, writes it into proxy-secrets.js's auth.localAdminPass (read
   by the proxy once, on first creation of that account), and prints
   it in the final summary. read_config_kv() reads it back from
   proxy-secrets.js so this works correctly on re-runs too (config
   already exists -> ensure_config's early-return path never sets
   CFG_PROXY_ADMIN_PASS in that run's shell, same reasoning as the
   existing SSO_HOST/PROXY_HOST/ADMIN_PASS readback).

2. Pass GIT_COMMIT build-args so the proxy/sso-manager images bake in
   their real commit hash instead of "unknown". Both submodules' .git
   is a pointer file, not a real repo, so the images can never resolve
   their own commit from inside the Docker build context no matter
   what (see theta42/proxy#133 and theta42/sso-manager-node#43) --
   only the host, where the submodule resolves correctly, can compute
   it. setup.sh does that with `git -C <submodule> rev-parse --short
   HEAD` right before each build and exports it for docker-compose.yml
   to pick up.

Verified end to end against a real ./setup.sh run (not just docker
build in isolation):
- Local admin password printed on first run, logs in successfully;
  the DEFAULT ("proxyadmin2"/"proxyadmin2") correctly does NOT.
- Re-running prints the SAME password (confirms the readback path
  works on re-runs, not just first-run).
- `docker exec proxy cat /app/.build_commit` and the equivalent for
  sso-manager both match `git -C <submodule> rev-parse --short HEAD`
  on the host — footer now shows the real hash instead of "unknown".
2026-07-14 22:32:44 -04:00
wmantly ecdd7ee9bd Bump proxy submodule to pick up the SSL fallback fix (#25)
proxy: 8aab9c7 -> 22f382b (#132) — fixes a TLS handshake failure that
broke SSL (including the self-signed fallback cert) for any
connection without an already-known target, e.g. no SNI at all or an
unregistered host. This affects every fresh theta-env install before
DNS/Host records are set up, so it's worth its own bump rather than
waiting to batch with other changes.
2026-07-14 21:34:57 -04:00
wmantly 4f011cbb69 Bump proxy + sso-manager-node submodules to latest master (#24)
proxy: 9d34ff9 -> 8aab9c7 — mobile table-responsive fixes (#131).
sso-manager-node: 3ceeeee -> f72e888 — mobile table-responsive +
group filter bar flex-wrap fixes (#42).
2026-07-14 21:19:39 -04:00
wmantly 5982d44a4a Merge pull request #23 from theta42/chore/bump-submodules-3
Bump proxy + sso-manager-node submodules to latest master
2026-07-14 21:00:02 -04:00
wmantly 8e5b9cd05f Bump proxy + sso-manager-node submodules to latest master
proxy: 1c7ad9a -> 9d34ff9 — target-hostname validation (#126), DuckDNS
domains/subdomains field-collision fix (#127), DuckDNS subdomain
double-suffix fix (#128), DnsProvider.create Domain key mismatch +
rollback fix (#129), footer added (#130).
sso-manager-node: 3d3b15b -> 3ceeeee — footer cleanup (#41).
2026-07-14 20:58:44 -04:00
wmantly 8f9e68bf5e Merge pull request #22 from theta42/feat/setup-self-update
setup.sh: pull theta-env itself before doing anything else
2026-07-14 11:39:38 -04:00
wmantly 79051b96e3 setup.sh: pull theta-env itself before doing anything else
Step 1 (submodule update) only refreshes proxy/sso-manager-node — it
never pulls setup.sh or this repo's own files. So on an existing
deployment, running ./setup.sh alone would build fresh submodule code
but execute a stale copy of the orchestration script itself (missing
whatever fixes landed in it, e.g. the CFG_DOMAIN rename or the
Host-registration step), unless the operator remembered to `git pull`
theta-env manually first.

Add a step 0 that fast-forwards the current branch to its upstream
before anything else runs, then re-execs the script so the rest of
the run uses the freshly-pulled version rather than the copy already
read into memory. Never blocks the run: skips silently if this isn't
a git checkout, is on a detached HEAD, or has no upstream configured
(all normal for e.g. a tarball download); warns and continues on the
current checkout for any other pull failure (offline, local changes
that prevent a fast-forward). Skip entirely with SKIP_SELF_UPDATE=1,
matching the existing SKIP_SUBMODULE_UPDATE convention.

Verified in an isolated scratch clone (not the working repo): pulling
a real commit forward triggers the re-exec and the second invocation
picks up the new HEAD; already-up-to-date and detached-HEAD cases are
both silent no-ops.
2026-07-14 11:38:17 -04:00
wmantly 3cb549f39c Merge pull request #21 from theta42/chore/bump-submodules-2
Bump proxy + sso-manager-node submodules to latest master
2026-07-14 01:29:12 -04:00
wmantly 0f7f7e2080 Bump proxy + sso-manager-node submodules to latest master
proxy: f926d92 -> 1c7ad9a — target-hostname validation fix (#126) and
the DuckDNS `domains`/`subdomains` field-collision fix (#127).
sso-manager-node: 2b11095 -> 3d3b15b — README rewrite (#40).
2026-07-14 01:27:55 -04:00
wmantly c45d030da1 Merge pull request #20 from theta42/fix/bootstrap-proxy-hosts
setup.sh: register SSO + proxy hostnames as Host records in the proxy
2026-07-14 01:07:53 -04:00
wmantly 425d92a137 setup.sh: register SSO + proxy hostnames as Host records in the proxy
The proxy routes every hostname it serves purely off a Host record
(ops/nginx_conf/proxy.conf has no default/self route — targetinfo.lua
does a Redis lookup per request, full stop). Nothing created these for
the SSO's own UI or the proxy's own management UI, so on a fresh
install https://<SSO_HOST> and https://<PROXY_HOST> both 404 despite
setup.sh's summary claiming they're "fronted by the proxy under TLS".

Add a step after the proxy is healthy that runs a short script inside
the proxy container calling its Host model directly (no HTTP API call,
since no authenticated session exists yet at this point in the run):
- <SSO_HOST> -> sso-manager:3001 (the Docker service)
- <PROXY_HOST> -> 127.0.0.1:3000 (the proxy's own management app)

Both created with sso_enabled: false — each app already gates its own
login, and SSO-gating the SSO's own login page would be circular.
Idempotent: skips a host that already exists.
2026-07-14 01:01:37 -04:00
wmantly 4bd2c5a8be Merge pull request #19 from theta42/chore/bump-submodules
Bump proxy + sso-manager-node submodules to latest master
2026-07-14 00:29:04 -04:00
wmantly 6f5878989d setup.sh: take a plain domain (CFG_DOMAIN), derive the LDAP base DN (#18)
Entering the base DN directly (CFG_BASE_DN=dc=foo,dc=bar) is fragile —
a missing comma between labels silently produces a malformed domain
(e.g. "theta42dc=duckdns.org" instead of "theta42.duckdns.org") with
no validation to catch it. Flip the direction: operators now set
CFG_DOMAIN to a plain domain (any number of labels — a DuckDNS domain
like foo.duckdns.org works the same as a normal one), and setup.sh
derives the base DN from it via the new dn_from_domain().

CFG_BASE_DN is still supported as an explicit override (e.g. to
namespace under an OU-style prefix) and is how migrated .env/proxy.env
deployments keep working, since domain_from_dn() still reads the
domain back out of an existing DN either way.
2026-07-14 00:28:39 -04:00
wmantly 1285a86ce6 Bump proxy + sso-manager-node submodules to latest master
proxy: adds DuckDNS as a free DNS provider, public-release doc cleanup,
and the README rewrite (#123, #124, #125).
sso-manager-node: public-release doc cleanup and the README rewrite
(#38, #40).
2026-07-14 00:27:51 -04:00
wmantly b43c3d0d1d docs: fix quickstart drift, add LICENSE, document admin bypass and LDAPS cert mount for public release (#17)
Cleanup pass ahead of the public release announcement:

- docs/index.md: fix the Quick Start block, which described a stale
  "edit config then re-run setup.sh a second time" flow. setup.sh now
  requires setup.env (with CFG_BASE_DN) before it will do anything, and
  builds + bootstraps + starts in a single run. Updated to match
  README.md's correct 4-line sequence.
- Add a standard MIT LICENSE at the repo root (theta42, 2026) so
  docs/index.md's "MIT License — see the repository for details" claim
  is actually true.
- docs/standalone.md: document the hardcoded auth.adminUsers:
  ['proxyadmin2'] local anti-lockout admin bypass written into every
  generated proxy-secrets.js — what it's for, that it requires a
  matching SSO user to actually use, and how to rename/extend/disable
  it.
- README.md + docker-compose.yml: fix the LDAPS strict-trust security
  note, which implied mounting the SSO's cert into the proxy was a
  config-only change. It also requires a docker-compose.yml edit
  (ldap-certs isn't mounted into the proxy service); added commented-out
  boilerplate for that mount and clarified the doc text.
- Also includes the pre-existing "Why use this instead of running the
  two separately?" README paragraph that was already staged as
  in-progress work.
- Verified: no Vagrant references, no emoji, and no hardcoded
  custom-domain URLs anywhere in this repo outside the proxy/ and
  sso-manager-node/ submodules; no docs/CNAME (github.io URL scheme
  confirmed).
- Added --- section dividers to docs/*.md to match README.md's
  formatting convention.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:10:55 -04:00
28 changed files with 1722 additions and 201 deletions
+13 -4
View File
@@ -14,7 +14,8 @@
LDAP_BASE_DN=dc=example,dc=com
# DNS domain (dc=foo,dc=bar -> foo.bar). Leave blank to derive from LDAP_BASE_DN.
LDAP_DOMAIN=
LDAP_ADMIN_PASS=change-me-ldap-admin-password
# LDAP admin password. MUST be changed. Leave blank and setup.sh will generate one.
LDAP_ADMIN_PASS=CHANGE-ME
ORG_NAME="My Org"
# ── Public hostnames (REQUIRED) ───────────────────────────────────────────────
@@ -30,13 +31,15 @@ PROXY_HOST=proxy.example.com
# app_sso_oauth_admin, and logs in as them to register the proxy OAuth client.
# Re-running setup.sh resets this password to BOOTSTRAP_ADMIN_PASS.
BOOTSTRAP_ADMIN_UID=admin
BOOTSTRAP_ADMIN_PASS=change-me-admin-password
# First admin password. MUST be changed. Leave blank and setup.sh will generate one.
BOOTSTRAP_ADMIN_PASS=CHANGE-ME
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
# ── Proxy LDAP service account (created by the bootstrap) ────────────────────
# The proxy binds to LDAP as cn=ldapclient,ou=people,<base> with this password.
# Re-running setup.sh resets it to LDAP_SERVICE_PASS.
LDAP_SERVICE_PASS=change-me-ldap-service-password
# LDAP service-account password. MUST be changed. Leave blank and setup.sh will generate one.
LDAP_SERVICE_PASS=CHANGE-ME
# ── OAuth JWT secret (REQUIRED — persist it) ────────────────────────────────
# Signs the SSO's access/refresh tokens. Generate with: openssl rand -hex 32
@@ -73,4 +76,10 @@ MGMT_BIND=0.0.0.0
# Defaults to LDAP_DOMAIN. Set to the hostname the proxy connects via
# (sso-manager inside the docker net uses the service name, which is in the
# cert's SAN, so the default is usually fine).
LDAP_CERT_CN=
LDAP_CERT_CN=
# ── Optional: LDAPS hostname shown on the SSO /integrations page ────────────────
# Leave blank to derive from the public SSO host (SSO_HOST). Set an internal-only
# name like 'ldap.internal.example.com' or 'sso-manager' so direct-LDAP clients
# don't need a public 636 port forward. See docs/ldap.md for network layouts.
LDAPS_HOST=
+41
View File
@@ -0,0 +1,41 @@
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.
on:
pull_request:
branches:
- master
push:
branches-ignore:
- master
jobs:
shellcheck:
name: Shellcheck setup.sh
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Syntax check
run: bash -n setup.sh
- name: Shellcheck
run: shellcheck -S warning setup.sh
bootstrap-syntax:
name: Syntax check bootstrap.js
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Syntax check
run: node --check bootstrap/bootstrap.js
+4 -1
View File
@@ -20,4 +20,7 @@ setup.env
*.ldif
# Docker Compose runtime artifacts
*.log
*.log
# Jekyll build output (docs/ site) — generated, not committed.
docs/_site
+4
View File
@@ -4,3 +4,7 @@
[submodule "proxy"]
path = proxy
url = https://github.com/theta42/proxy.git
[submodule "jump-host"]
path = jump-host
url = https://github.com/theta42/jump-host.git
branch = master
+420
View File
@@ -0,0 +1,420 @@
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`). Entries here cover theta-env's own
orchestration code; see each submodule's own `CHANGELOG.md`
([proxy](https://github.com/theta42/proxy/blob/master/CHANGELOG.md),
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md))
for what changed inside the apps it composes.
## [Unreleased]
## [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
- **Optional SSH jump host** (theta42/jump-host) as a third, opt-in submodule. Enable with `CFG_JUMP_HOST_ENABLED=true` in `setup.env`: setup.sh clones/tag-tracks the submodule and builds it behind the `jump-host` compose profile, the bootstrap mints a directory API token and writes `./config/jump-secrets.js` (LDAP admin bind so it can inject users' `sshPublicKey`), the jump host is registered as a proxy Host (its web UI) and seeded as a directory service. Users then `ssh uid_-_host@jump.<domain>` (WinSCP-friendly) or `ssh uid@jump.<domain>` for a TUI host picker; the web UI on :3002 shows audit + metrics. Off by default — existing installs are unaffected.
## [1.3.4] - 2026-07-23
### Bumped
- sso-manager-node -> [v1.3.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.1)
sso-manager-node 1.3.1:
### Added
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
### Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
### theta-env own changes
### Added
- `CFG_SITE_NAME` in `setup.env` (right below `CFG_DOMAIN`, default `local`): names the SSO directory site the stack registers itself under — slug `site_<name>`, matching the `parentSlug` convention ldap-client-joined Linux hosts use, so they land under the same site.
- The directory seed now collects real host facts on the machine (hostname, IP, MAC of the default-route interface, OS pretty-name, kernel — same collection as `ldap-client/index.sh`) and registers the stack host as `host_<hostname>` with that metadata, plus fills in each service's internal port and git repo (`sso-manager` 3001, `proxy` 3000, `openldap` 389/636, `openresty` 443). Existing resources from the earlier seed layout (`stack-host`, domain-slug site) are adopted in place — seed metadata only fills fields the operator hasn't set, never overwrites.
- The bootstrap now seeds the SSO directory with the stack's own resources: a site (from the configured domain), a "Stack host", and the SSO Manager + Proxy services (with their public URLs in metadata), linking the proxy's auto-registered OAuth client under its service. Also seeds the two non-obvious services the stack runs: the OpenLDAP directory (advertising the `ldaps://` endpoint Linux hosts and LDAP-native apps bind to, honoring `ldap.ldapsHost`) and the OpenResty edge (the 80/443 data plane every hostname flows through, with a wildcard `https://*.<domain>` address). The Directory page is populated out of the box instead of starting empty. Idempotent — resources whose slug already exists are operator-owned and never touched, and a seed failure only warns (never fails a bring-up, e.g. against an older sso-manager image without `/api/directory`).
## [1.3.3] - 2026-07-23
### Bumped
- sso-manager-node -> [v1.3.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.0) (from v1.1.18; includes the intermediate v1.2.1 release)
sso-manager-node 1.3.0:
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
sso-manager-node 1.2.1:
### Added
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
## [1.3.2] - 2026-07-21
### Bumped
- proxy -> [v1.2.2](https://github.com/theta42/proxy/releases/tag/v1.2.2)
proxy:
### Fixed
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock actually installed provides `resty.roundrobin` instead, with a different constructor API. Fixed `targetinfo.lua` to use the real module — verified end-to-end that requests now round-robin across targets with no Lua errors.
## [1.3.1] - 2026-07-21
### Bumped
- proxy -> [v1.2.1](https://github.com/theta42/proxy/releases/tag/v1.2.1)
- sso-manager-node -> [v1.1.18](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.18)
proxy:
### Fixed
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
### Changed
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
sso-manager-node:
### Added
- N-Way Multi-Master LDAP replication: `LDAP_SERVER_ID` + `LDAP_REPLICATION_HOSTS` configure `syncrepl` peers in the bundled OpenLDAP, and a new `/sites` page (nav: **Sites**) shows each configured peer's LDAP URL and live reachability.
- A `location` property on users, editable from the profile and user-edit forms.
### Fixed
- `/sites` (added above) 500'd on every load: `views/sites.ejs` included nonexistent partials `header`/`footer` instead of this app's actual `top`/`bottom`. Fixed to match every other view.
### Changed
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
### theta-env own changes
- Refreshed `docs/images/sso-dashboard.png` and `docs/images/proxy-hosts.png` to match the submodules' updated screenshots.
## [1.1.20] - 2026-07-20
### Bumped
- proxy -> [v1.1.17](https://github.com/theta42/proxy/releases/tag/v1.1.17)
proxy:
### Fixed
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
## [1.1.19] - 2026-07-18
### Bumped
- sso-manager-node -> [v1.1.17](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.17)
sso-manager-node:
### Added
- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options for advertising a separate, internal-only LDAPS hostname on the `/integrations` page. Falls back to the public OAuth issuer host when unset.
- Contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname, why port 636 should not be forwarded publicly, and the recommended internal-DNS / Docker-internal alternatives.
- Tests for the `/integrations` route's LDAPS URL derivation and `ldapsHost` override.
### Changed
- `nodejs/package.json` / `package-lock.json` version bumped to `1.1.17`.
- `routes/index.js` now derives the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host.
- `docs/configuration.md`, `docs/ldap.md`, `DEPLOYMENT.md`, and `secrets.js.example` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
### theta-env own changes
- `setup.env.example` adds optional `CFG_LDAPS_HOST` for the internal LDAPS hostname.
- `setup.sh` passes `CFG_LDAPS_HOST` into the generated `./config/sso-secrets.js` as `ldap.ldapsHost`.
- `config.example/sso-secrets.js.example` documents `ldap.ldapsHost` / `ldap.ldapsPort`.
- `.env.example` adds `LDAPS_HOST` for legacy `.env` migrations.
- `docker-compose.yml` comments warn against forwarding 636 to the public internet.
- `README.md` explains the `CFG_LDAPS_HOST` recommendation in the port-forwarding section.
## [1.1.18] - 2026-07-18
### Bumped
- proxy -> [v1.1.16](https://github.com/theta42/proxy/releases/tag/v1.1.16)
- sso-manager-node -> [v1.1.16](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.16)
proxy:
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json`, corrected the repository URL to `https://github.com/theta42/proxy.git`, and fixed the MIT `LICENSE` copyright line.
- Genericized committed config defaults in `conf/base.js` and `conf/development.js` (`example.com` / `localhost` instead of theta42 infrastructure).
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default.
### Security
- Sanitized rendered docs HTML with `xss` in `routes/docs.js`.
- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`.
### Fixed
- The global error handler no longer leaks `err.keys`, stack traces, or internal details in JSON responses.
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism.
sso-manager-node:
### Security
- Hardened LDAP filter and DN construction against injection in `models/group_ldap.js` and `models/user_ldap.js`.
- Replaced `Math.random()`-based token/UUID/OTP generation with `crypto.randomUUID()` / `crypto.randomInt()` in `models/token.js`, `models/oauth_code.js`, and `models/oauth_client.js`.
- Refused startup when `oauth.jwtSecret` is missing or placeholder.
- Sanitized rendered docs/Terms-of-Service HTML with `xss` to block malicious markdown output.
- Removed full-object `console.log` of new-user data and reduced login error logging to `name`/`message` only.
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json` and bumped version to `1.1.16`.
### Fixed
- `models/email.js`: fixed from-address template rendering bug.
### theta-env own changes
- `CHANGELOG.md` now embeds the full app-level release notes for each submodule bump, not just links.
- `.env.example` no longer ships realistic-looking default passwords; values are clearly placeholders.
- `config.example/*.js.example` comments now describe the actual `CONF_SECRETS` env-var loading mechanism.
- `setup.sh` summary no longer prints generated passwords to stdout; it points to `./config/*.js`.
- `bootstrap/bootstrap.js` fails hard instead of falling back to weak default passwords when config is missing.
## [1.1.17] - 2026-07-18
### Bumped
- proxy -> [v1.1.15](https://github.com/theta42/proxy/releases/tag/v1.1.15)
- sso-manager-node -> [v1.1.15](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.15)
Both apps' bare-metal `install.sh` now installs to `/opt/theta42/<app>` and seeds `/etc/<app>/secrets.js` on first run, matching a `wget -O - .../install.sh | sudo bash` one-line install for both (previously proxy-only); re-running it prints the version it's updating from/to. sso-manager-node's installer was rewritten from a flag-driven, copy-based script into the same idempotent git-clone pattern proxy already used, and now bootstraps OpenLDAP itself on first run instead of requiring the repo to already be checked out locally. None of this affects the Docker/unified-stack deployment this repo orchestrates — bare-metal-only.
## [1.1.16] - 2026-07-18
### Bumped
- proxy -> [v1.1.14](https://github.com/theta42/proxy/releases/tag/v1.1.14)
- sso-manager-node -> [v1.1.14](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.14)
Both: bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0.
### Changed
- `./config/sso-secrets.js` and `./config/proxy-secrets.js` are now loaded via each app's `CONF_SECRETS` env var (set by the entrypoint) instead of being symlinked into `/app/conf/secrets.js` — neither container needs write access to its own `conf/` directory anymore. No change to the config file format or bind mounts; existing `./config/` directories keep working as-is.
## [1.1.15] - 2026-07-17
### Bumped
- proxy -> [v1.1.13](https://github.com/theta42/proxy/releases/tag/v1.1.13)
- sso-manager-node -> [v1.1.13](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.13)
proxy:
### Fixed
- The host edit form's "Parent Wildcard" option stayed greyed out even when a valid wildcard actually existed for that host, so an already-created host could never be switched onto one from the edit modal (only brand-new hosts, via the field's `keyup` handler, ever saw it become available). The underlying `/host/lookup/:item` check also had the same self-match issue as the recently-fixed backend bug: it resolved an already-existing host to its own record instead of a sibling wildcard. Added a dedicated `/host/wildcard-parent/:item` endpoint that checks both directions, and the edit form now actually runs the check when it opens.
- Fixed an nginx startup warning: `the "listen ... http2" directive is deprecated, use the "http2" directive instead`. Migrated to the standalone `http2 on;` directive (nginx 1.25.1+).
### Added
- Four new plain-language docs aimed at less technical readers, replacing the system-design-level Architecture/Installation docs as the target of most card help links: **Hosts & HTTPS**, **DNS Providers**, **Users, Groups & Permissions**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed entirely) now has a help link.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL (by registered slug, falling back to the doc's real filename), the same way image paths already were.
sso-manager-node:
### Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: **Accounts, Groups & Managers**, **Connecting Apps (SSO)**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links (`ldap.html`, `index.html`, etc.) never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL, the same way image paths already were.
- The new concept docs' cross-links (`concepts-accounts.html` etc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (`/docs/accounts`). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
## [1.1.14] - 2026-07-17
### Bumped
- proxy -> [v1.1.11](https://github.com/theta42/proxy/releases/tag/v1.1.11)
- sso-manager-node -> [v1.1.11](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.11)
Both: moved the help (❓) link out of the global header and onto each relevant card individually, so it deep-links straight to the doc that actually covers that card instead of one generic per-page guess.
## [1.1.13] - 2026-07-17
### Bumped
- proxy -> [v1.1.10](https://github.com/theta42/proxy/releases/tag/v1.1.10)
- sso-manager-node -> [v1.1.10](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.10)
Both: added a help icon (❓) in the top-right header that deep-links to the doc most relevant to the current page, and made the in-app docs viewer (`/docs`) searchable (a simple line-substring search over the local doc set, no new dependency, still works with no internet access).
## [1.1.12] - 2026-07-17
### Bumped
- proxy -> [v1.1.9](https://github.com/theta42/proxy/releases/tag/v1.1.9)
proxy:
### Added
- The host list now shows who created each host, and when.
- Plain (non-wildcard) hosts can now be renamed after creation — the hostname field is no longer permanently locked. Wildcard hosts, wildcard children, and auto-created subdomain cache entries stay locked, since other records reference them by name.
- More inline help text on the host create/edit form (Target SSL, wildcard matching behavior).
### Fixed
- The host create/edit modal's tabs could overflow awkwardly on narrow (mobile) screens — they now scroll horizontally instead.
- Fixed a bug in the vendored `model-redis` library's record-rename path: renaming a record's primary key while another `always`-type field (e.g. `updated_on`) is defined earlier in the schema left a stray, incomplete hash behind under the old key, making that name permanently unavailable for reuse. Worked around in `Host.prototype.update()`.
## [1.1.11] - 2026-07-17
### Bumped
- proxy -> [v1.1.8](https://github.com/theta42/proxy/releases/tag/v1.1.8)
proxy:
### Fixed
- **Couldn't attach an existing host to a parent wildcard.** The host edit form's "Parent Wildcard" option submitted correctly, but `Host.prototype.update()` had no `challengeType` handling at all (only `Host.create()` did) — selecting it and saving silently did nothing. Added the same wildcard-parent lookup to `update()`.
- **Couldn't register a wildcard's own base domain as a host.** A wildcard cert's `altNames` already cover both the base domain and `*.base domain`, but the lookup tree stores the wildcard one level below its base domain, and a lookup for the bare base domain landed on that empty parent node and found nothing — even though the already-issued cert covers it. `buildLookUpObj()` now also stamps the parent node so this resolves correctly, without re-issuing or duplicating the cert.
## [1.1.10] - 2026-07-17
### Bumped
- sso-manager-node -> [v1.1.9](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.9)
sso-manager-node:
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.9] - 2026-07-17
### Bumped
- sso-manager-node -> [v1.1.8](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.8)
sso-manager-node:
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.8] - 2026-07-17
### Bumped
- sso-manager-node -> [v1.1.7](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.7)
sso-manager-node:
### Changed
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
## [1.1.7] - 2026-07-16
### Bumped
- proxy -> [v1.1.7](https://github.com/theta42/proxy/releases/tag/v1.1.7)
- sso-manager-node -> [v1.1.6](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.6)
Both: redesigned the GitHub Pages docs site to match each app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`), and mobile-responsive layout. theta-env's own docs site got the same treatment in this release too (see below).
### Changed
- theta-env's own docs site redesigned the same way -- dark navbar/footer using the shared theta42 logo (this repo has no app UI of its own), cross-page nav, SEO, mobile-responsive. `docs/index.md`'s "More docs" section removed (redundant with the new nav).
- Added `docs/_site` to `.gitignore` (missing entirely before).
## [1.1.6] - 2026-07-16
### Bumped
- proxy -> [v1.1.6](https://github.com/theta42/proxy/releases/tag/v1.1.6)
proxy: Hosts admin UI's Authentication tab radios (Off / Basic / SSO) had no shared `name`, so clicking one didn't uncheck the others. Added `name="auth_mode"` to restore standard exclusive radio-group behavior.
## [1.1.5] - 2026-07-16
### Bumped
- proxy -> [v1.1.5](https://github.com/theta42/proxy/releases/tag/v1.1.5)
- sso-manager-node -> [v1.1.5](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.5)
Both: bumped `jq-repeat` 2.0.1 -> 2.1.0. proxy fixed real breakage from the removed `__setPut`/`__setTake` API (insert/remove row hooks in the admin UI); sso-manager-node fixed a stale-data flash in the edit-profile flow caused by `update()`'s new throttling.
## [1.1.4] - 2026-07-16
### Added
- White-label support in both proxy and sso-manager-node -- `<title>`, navbar brand, and logo are now conf-driven (`conf.name`/`conf.logo`) instead of hardcoded. Closes [proxy#45](https://github.com/theta42/proxy/issues/45) and [sso-manager-node#6](https://github.com/theta42/sso-manager-node/issues/6).
### Fixed
- sso-manager-node: the bundled default LDAP ppolicy had `pwdLockout: FALSE`, silently making "deactivate user" not actually block login. Fixed, with a drift-correction path for already-deployed instances.
### Bumped
- proxy -> [v1.1.4](https://github.com/theta42/proxy/releases/tag/v1.1.4)
- sso-manager-node -> [v1.1.4](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.4)
## [1.1.3] - 2026-07-16
### Added
- `CHANGELOG.md` (this file). Closes [#43](https://github.com/theta42/theta-env/issues/43).
### Bumped
- proxy -> [v1.1.3](https://github.com/theta42/proxy/releases/tag/v1.1.3)
- sso-manager-node -> [v1.1.3](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.3)
## [1.1.2] - 2026-07-16
### Changed
- `docs/index.md` (the published site's home page) never linked to `architecture.md`, `quickstart.md`, or `standalone.md` — added a "More docs" section so they're reachable from the site instead of only by direct URL.
### Bumped
- proxy -> [v1.1.2](https://github.com/theta42/proxy/releases/tag/v1.1.2)
- sso-manager-node -> [v1.1.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.2)
## [1.1.1] - 2026-07-16
### Changed
- `setup.sh` now pins `proxy` and `sso-manager-node` to their latest release tag (`vX.Y.Z`) instead of the tip of `master`. A rebuild now always lands on a tagged, versioned release of each app rather than whatever was most recently merged upstream.
### Bumped
- proxy -> [v1.1.1](https://github.com/theta42/proxy/releases/tag/v1.1.1)
- sso-manager-node -> [v1.1.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.1)
## [1.1.0] - 2026-07-16
First tagged release. Establishes the `vX.Y.Z` tag convention going forward.
### Added
- `setup.sh` now reports which submodules actually moved to a newer commit during an update, instead of updating silently.
### Bumped
- 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
[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
[1.1.14]: https://github.com/theta42/theta-env/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/theta-env/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/theta-env/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/theta-env/compare/v1.1.10...v1.1.11
[1.1.10]: https://github.com/theta42/theta-env/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/theta-env/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/theta-env/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/theta-env/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/theta-env/compare/v1.1.5...v1.1.6
[1.1.5]: https://github.com/theta42/theta-env/compare/v1.1.4...v1.1.5
[1.1.4]: https://github.com/theta42/theta-env/compare/v1.1.3...v1.1.4
[1.1.3]: https://github.com/theta42/theta-env/compare/v1.1.2...v1.1.3
[1.1.2]: https://github.com/theta42/theta-env/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/theta-env/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/theta-env/releases/tag/v1.1.0
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 theta42
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+61 -16
View File
@@ -16,6 +16,25 @@ Each project still runs **standalone** (`docker compose up` in its own folder);
this repo just composes them and automates the first-run glue so they find each
other.
**Documentation:** [https://theta42.github.io/theta-env/](https://theta42.github.io/theta-env/)
## Screenshots
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
| SSO Manager Dashboard | Proxy Hosts |
| --- | --- |
| [![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.
```
┌──────────────────────────────────────────────┐
│ your browser / apps │
@@ -41,6 +60,10 @@ It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). Legacy apps can still bind to LDAPS on the SSO
directly.
- **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.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
---
## Before you begin
@@ -55,10 +78,10 @@ real TLS certificates for it via Let's Encrypt. A `.local` or made-up name only
gets you a self-signed cert (browsers will warn — fine for testing, painful for
daily use).
The domain is the **one** value you set in `setup.env` (as the LDAP base DN,
e.g. `CFG_BASE_DN=dc=lab,dc=example,dc=com` for `lab.example.com`) — see
*Quickstart*. The SSO/proxy hostnames default to `sso.<domain>` /
`proxy.<domain>`, derived from it.
The domain is the **one** value you set in `setup.env` (e.g.
`CFG_DOMAIN=lab.example.com`) — see *Quickstart*. The SSO/proxy hostnames
default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN
(`dc=lab,dc=example,dc=com`) is built from it automatically.
### 2. At least two hostnames, pointing at your public IP
@@ -101,6 +124,10 @@ Optional extra ports (only if you need them):
- **636** (LDAPS) — only if a legacy app on another machine binds to LDAP
directly over the network. The proxy itself reaches LDAP over the internal
Docker network, so you do **not** need to expose 636 for the stack to work.
**Do not forward 636 to the public internet.** If you need LAN clients to bind
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
SAN. The default shows the public SSO hostname, which implies a public route.
### 4. Docker + Docker Compose
@@ -114,12 +141,13 @@ standalone (`docker-compose`) both work.
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
```
Your domain is entered **once**, as the LDAP base DN in `setup.env` (e.g.
`CFG_BASE_DN=dc=lab,dc=example,dc=com` for the domain `lab.example.com`). The
Your domain is entered **once** in `setup.env` (e.g.
`CFG_DOMAIN=lab.example.com`) — the LDAP base DN (`dc=lab,dc=example,dc=com`)
is derived from it, however many labels it has. The
first `./setup.sh` reads `setup.env` and generates `./config/sso-secrets.js` +
`./config/proxy-secrets.js` with that domain filled in everywhere (hostnames
default to `sso.<domain>` / `proxy.<domain>`) plus random secrets, then builds
@@ -142,12 +170,18 @@ operator-owned and `setup.env` is ignored.
- registers the proxy as an OIDC client in the SSO and **writes the generated
client id + secret back into `./config/proxy-secrets.js`**.
4. Builds + starts the proxy container, waits for it to be healthy.
5. Prints your first admin login + the public URLs.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy
(directly via its Host model, inside the proxy container) — the proxy
routes every hostname it serves off a Host record, including its own
management UI and the SSO's UI, so without this step those two URLs
would 404. Idempotent; skips a host that already exists.
6. Prints your first admin login + the public URLs.
### Configuration — `./config/` (no `.env` files)
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
read by each app's `@simpleworkjs/conf` from a symlinked `secrets.js`:
read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which
the entrypoint points at the mounted file:
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
@@ -412,8 +446,12 @@ exactly in the bootstrap) so the SSO can verify them on bind.
off-host (see *Backups and restore*).
3. **LDAPS uses the SSO's self-signed cert by default.** The proxy binds with
`ldap.tlsOptions.rejectUnauthorized=false` (in `proxy-secrets.js`). For strict
trust, mount the SSO's cert (`ldap-certs` volume) into the proxy and set
`ldap.tlsOptions.ca=<path>` in `./config/proxy-secrets.js`.
trust, this is a **two-step change, not config-only**: (a) edit
`docker-compose.yml` to also mount the `ldap-certs` volume into the `proxy`
service (it's currently only mounted into `sso-manager`) — see the
commented-out boilerplate in the `proxy` service's `volumes:` block — then
(b) set `ldap.tlsOptions.ca=<path>` in `./config/proxy-secrets.js` to the
mounted cert path and `docker compose up -d proxy` to pick up the new mount.
4. **Re-running `setup.sh` resets the bootstrap admin + service passwords to
the `./config/` values.** If you change a user's password in the SSO UI
later, re-running `setup.sh` will reset the bootstrap admin's password back
@@ -428,7 +466,7 @@ exactly in the bootstrap) so the SSO can verify them on bind.
```
theta-env/
├── setup.env.example # first-run config template — cp to setup.env, set CFG_BASE_DN
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
├── config.example/ # committed annotated config templates (copy to ./config/)
├── docker-compose.yml # sso-manager + proxy on one bridge net
├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups)
@@ -442,7 +480,14 @@ theta-env/
gitignored `./config/` (`sso-secrets.js` + `proxy-secrets.js`) and snapshots to
the gitignored `./backups/` before each rebuild.
`./setup.sh` updates both submodules to the latest of their tracked remote
branch before building, so each run builds current upstream — no manual
`git submodule update --remote` needed. To lock to the pinned commits (offline
rebuild, or a deliberate pin), run `SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
`./setup.sh` updates both submodules to their latest `vX.Y.Z` release tag
before building — not the tip of `master` — so each run builds the newest
tagged release of each app, not whatever's most recently merged upstream. To
lock to the pinned commits (offline rebuild, or a deliberate pin), run
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-env release
(and each submodule's own `CHANGELOG.md` —
[proxy](https://github.com/theta42/proxy/blob/master/CHANGELOG.md),
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)
— for what changed inside the apps themselves).
+293 -4
View File
@@ -44,8 +44,15 @@ const fs = require('fs');
const sso = require('/config/sso-secrets.js');
const proxy = require('/config/proxy-secrets.js');
const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com';
const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin';
function requireConf(value, name) {
if (value === undefined || value === null || value === '' || value === 'CHANGE-ME') {
throw new Error(`${name} is not configured in /config/sso-secrets.js`);
}
return value;
}
const BASE_DN = requireConf((sso.stack && sso.stack.ldapBaseDn), 'stack.ldapBaseDn');
const ADMIN_PASS = requireConf((sso.ldap && sso.ldap.bindPassword), 'ldap.bindPassword');
const BIND_DN = `cn=admin,${BASE_DN}`;
const LDAP_URL = 'ldap://localhost:389';
@@ -53,9 +60,9 @@ const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
// The first admin *user's* password (cn=<uid>,ou=people,<base>). Distinct from
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password —
// two different accounts, two different secrets.
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin';
const ADMIN_USER_PASS = requireConf((sso.bootstrap && sso.bootstrap.adminPass), 'bootstrap.adminPass');
const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || '';
const SVC_PASS = sso.serviceAccountPass || 'service';
const SVC_PASS = requireConf(sso.serviceAccountPass, 'serviceAccountPass');
const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com';
const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com';
@@ -261,6 +268,182 @@ async function rotateClient(token, id) {
return { id, secret: data.client_secret };
}
// ── 5. Seed the SSO directory with the stack's own resources ────────────────
// The Directory page (site → host → service hierarchy) starts empty even
// though this stack knows exactly what it deployed. Seed it: one site (the
// domain), one host (the box this stack runs on), and the two services
// (SSO Manager + proxy), then link the proxy's OAuth client under its
// service. Idempotent — existing slugs are left untouched, so operator
// edits (renames, metadata, extra resources) survive re-runs. Failures
// here only warn: the directory is a nicety, never worth failing a
// bring-up over (e.g. an older sso-manager image without /api/directory).
const DOMAIN = (sso.stack && sso.stack.ldapDomain) || '';
const ORG = sso.name || 'SSO Manager';
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
async function dirGet(token, path) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/directory-admin/${path} failed (${res.status})`);
return res.json();
}
async function dirPost(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
async function dirPut(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'PUT',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`PUT /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
// The site the stack registers itself under. Also the default "Location
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
const SITE_NAME = (sso.stack && sso.stack.siteName) || 'local';
// Host facts, collected by setup.sh ON THE HOST (inside this container
// hostname/uname describe the container) and passed via the exec env. Same
// fields ldap-client/index.sh registers, so stack hosts and ldap-client-
// joined hosts carry identical metadata.
const HOST_FACTS = {
name: process.env.STACK_HOST_NAME || '',
ip: process.env.STACK_HOST_IP || '',
mac: process.env.STACK_HOST_MAC || '',
os: process.env.STACK_HOST_OS || '',
kernel: process.env.STACK_HOST_KERNEL || '',
};
async function seedDirectory(token, clientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Create a resource unless its slug (or a legacy alternate from an earlier
// seed layout) already exists. On an existing resource, seed metadata keys
// it doesn't have yet are filled in — operator-set values always win and
// are never overwritten.
async function ensure(kind, name, slug, parentId, metadata, altSlugs) {
const slugs = [slug, ...(altSlugs || [])];
const found = resources.find((r) => slugs.includes(r.slug));
if (found) {
const have = found.metadata || {};
const missing = Object.entries(metadata || {})
.filter(([k, v]) => (have[k] === undefined || have[k] === '') && v !== '');
if (missing.length) {
const merged = { ...have };
for (const [k, v] of missing) merged[k] = v;
// metadata-only PUT: no kind/hostId in the body, so the route's
// parent validation and edge rewiring are not triggered.
await dirPut(token, `resources/${found.id}`, { metadata: merged });
found.metadata = merged;
log(` directory: ${kind} '${found.slug}' exists — filled ${missing.map(([k]) => k).join(', ')}`);
} else {
log(` directory: ${kind} '${found.slug}' exists — keeping`);
}
return found;
}
const body = { kind, name, slug, metadata: metadata || {} };
if (parentId) body.hostId = parentId; // POST creates the parent edge
const created = (await dirPost(token, 'resources', body)).results;
resources.push(created);
log(` directory: created ${kind} '${slug}'`);
return created;
}
// site_<name> / host_<name> slug convention matches ldap-client/index.sh.
// altSlugs grandfather in the layout the first seed release used.
const site = await ensure('site', SITE_NAME, `site_${slugify(SITE_NAME)}`, null,
{ isCurrentSite: true },
[slugify(DOMAIN || ORG)]);
const hostSlug = HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : 'stack-host';
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
subType: 'linux',
ip: HOST_FACTS.ip,
macAddress: HOST_FACTS.mac,
os: HOST_FACTS.os,
kernel: HOST_FACTS.kernel,
}, ['stack-host']);
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
address: `https://${SSO_HOST}`,
port: 3001,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'web',
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
address: `https://${PROXY_HOST}`,
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'web',
});
// OpenLDAP is independently consumed — Linux hosts authenticate against it
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
// (see the SSO's /integrations page) — so it gets its own entry. Advertise
// the operator-configured LDAPS hostname when set, else the SSO host.
// The bundled slapd's image/config live in sso-manager-node.
const LDAPS_HOST = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
await ensure('service', 'OpenLDAP Directory', 'openldap', host.id, {
address: `ldaps://${LDAPS_HOST}:636`,
port: 389,
externalPort: 636,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'openldap',
});
// Wildcard address: OpenResty fronts every host under the domain (same
// */** wildcard convention the proxy's Host records use). Its config lives
// in the proxy repo (ops/nginx_conf).
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
port: 443,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'openresty',
});
// Optional SSH jump host service.
if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) {
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
subType: 'ssh',
});
}
// Link the proxy's OAuth client (Resource-backed since sso-manager 1.3.0)
// under its service, if it appears in the directory and isn't linked yet.
if (clientId) {
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'`);
}
}
}
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
// (which reads that file) can use them. Only the clientId/clientSecret lines
// are touched; the rest of the file (operator edits, comments) is preserved.
@@ -291,6 +474,87 @@ function writeProxyCreds(id, secret) {
}
}
// ── 6. Optional: provision the SSH jump host ────────────────────────────────
// When CFG_JUMP_HOST_ENABLED=true, the jump host needs: a directory API token
// (to resolve which hosts a user may reach), an LDAP bind account that can
// WRITE the sshPublicKey attribute (it injects its own key on first use), and
// a config file it reads. We write /config/jump-secrets.js deriving LDAP/site
// from sso-secrets.js + a freshly minted API token. The bundled jump host
// binds as cn=admin (already able to write sshPublicKey) — hardened bare-metal
// deployments should use a scoped account + attribute ACL instead (see the
// jump-host README). Idempotent: skips if the file already has a real token.
const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '');
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
const JUMP_SECRETS = '/config/jump-secrets.js';
const JUMP_TOKEN_NAME = 'theta-jump-host';
async function mintApiToken(token, name) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: 'theta-env jump host (auto-registered)' }),
});
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
const data = await res.json();
const raw = data.token || (data.results && data.results.token) || data.raw_token;
if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`);
return raw;
}
function jumpFileHasToken() {
try {
const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
return /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
} catch (_) { return false; }
}
function writeJumpSecrets(apiToken) {
const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict';
// Generated by theta-env bootstrap. The jump host reads this via
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
// sshPublicKey attribute (key injection); for a hardened deployment use a
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
module.exports = {
\tname: ${JSON.stringify(sso.name || 'SSO Manager')},
\tldap: {
\t\turl: 'ldap://sso-manager:389',
\t\tbindDN: ${JSON.stringify(BIND_DN)},
\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)},
\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)},
\t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)},
\t\ttlsOptions: { rejectUnauthorized: false },
\t},
\tsso: {
\t\turl: 'http://sso-manager:3001',
\t\tapiToken: ${JSON.stringify(apiToken)},
\t},
\tssh: {
\t\tlistenPort: 2222,
\t\thostKeyPath: '/var/lib/jump-host/keys',
\t\tpasswordAuth: 'off',
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
\t},
\tweb: { port: 3002 },
\tauth: { adminGroups: ['app_sso_admin'] },
\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } },
\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} },
};
`;
fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 });
}
async function provisionJumpHost(token) {
if (jumpFileHasToken()) {
log('Jump host: /config/jump-secrets.js already has an API token — keeping.');
return;
}
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
writeJumpSecrets(apiToken);
log('Jump host: wrote /config/jump-secrets.js (minted directory API token).');
}
(async function main() {
try {
log(`Base DN: ${BASE_DN}`);
@@ -300,6 +564,7 @@ function writeProxyCreds(id, secret) {
const list = await listClients(token);
// Find the proxy's client: by id if we have usable creds, else by name.
let resolvedClientId = '';
let client = null;
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
@@ -312,6 +577,7 @@ function writeProxyCreds(id, secret) {
out('CLIENT_ID', EXISTING_ID);
out('CLIENT_SECRET', EXISTING_SECRET);
out('ALREADY_CONFIGURED', '1');
resolvedClientId = EXISTING_ID;
} else if (client) {
// Client exists but the file has no recoverable secret for it — rotate
// so the proxy gets a fresh secret it can actually read, then write back.
@@ -321,6 +587,7 @@ function writeProxyCreds(id, secret) {
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
} else {
// No client yet — create one and write the generated creds back.
const { id, secret } = await createClient(token);
@@ -328,7 +595,29 @@ function writeProxyCreds(id, secret) {
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
}
// Provision the jump host (mint token + write config) when enabled.
// Warn-only — never fail the whole bring-up over the optional service.
if (JUMP_ENABLED) {
try {
await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
}
// Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue.
try {
log('Seeding directory resources...');
await seedDirectory(token, resolvedClientId);
} catch (e) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
log('Done.');
process.exit(0);
} catch (e) {
+2 -2
View File
@@ -5,8 +5,8 @@
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
// file documents the shape for manual editing / reference.
//
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh
// symlinks it to /app/conf/secrets.js). Never commit ./config/.
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
// CONF_SECRETS to point at it). Never commit ./config/.
module.exports = {
oidc: {
+5 -2
View File
@@ -4,8 +4,8 @@
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
// documents the shape for manual editing / reference.
//
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh symlinks
// it to /app/conf/secrets.js). The app ignores the extra stack/bootstrap/
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
// CONF_SECRETS to point at it). The app ignores the extra stack/bootstrap/
// serviceAccountPass keys (read by the orchestrator). Back this up off-host —
// it holds all SSO secrets. Never commit ./config/.
@@ -17,6 +17,9 @@ module.exports = {
bindPassword: 'CHANGE-ME', // slapd root + app bind password
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: internal-only hostname
// shown on /integrations for direct LDAPS binds. Empty -> derive from issuer.
// ldapsPort: 636,
},
smtp: { // optional; leave host '' to skip
host: '', port: 587, secure: false,
+56 -8
View File
@@ -13,11 +13,12 @@
# Config + secrets live in bind-mounted ./config/ (gitignored):
# ./config/sso-secrets.js — SSO app + orchestrator config
# ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config
# Each app's entrypoint symlinks its file into /app/conf/secrets.js so
# @simpleworkjs/conf reads it. No app_* env is passed (app_* env would override
# secrets.js). The sso-manager mounts ./config read-write so the bootstrap can
# write the generated OAuth client creds back into proxy-secrets.js; the proxy
# mounts it read-only.
# Each app's entrypoint points CONF_SECRETS at its file so @simpleworkjs/conf
# (>= 1.2.0) reads it directly -- no app_* env is passed (app_* env would
# override secrets.js), and no write access to /app/conf is needed. The
# sso-manager mounts ./config read-write so the bootstrap can write the
# generated OAuth client creds back into proxy-secrets.js; the proxy mounts
# it read-only.
#
# Compose only interpolates the port defaults below — there is no .env file.
# First-run wiring (LDAP service account, first admin, OAuth client) is
@@ -29,6 +30,12 @@ services:
build:
context: ./sso-manager-node
dockerfile: Dockerfile.openldap
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# 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:-}
container_name: sso-manager
restart: unless-stopped
networks: [theta-net]
@@ -39,6 +46,8 @@ services:
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
# in sso-secrets.js) and do NOT forward 636 to the public internet.
- "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
environment:
@@ -47,10 +56,12 @@ services:
# reads that are not part of its conf tree.
- NODE_ENV=production
- NODE_PORT=3001
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
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
# entrypoint symlinks /config/sso-secrets.js -> /app/conf/secrets.js.
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
- ./config:/config
# Persist the LDAP database across container recreation.
- ldap-data:/var/lib/ldap
@@ -74,6 +85,12 @@ services:
build:
context: ./proxy
dockerfile: Dockerfile
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C proxy rev-parse --short HEAD).
GIT_COMMIT: ${PROXY_GIT_COMMIT:-}
container_name: proxy
restart: unless-stopped
networks: [theta-net]
@@ -96,13 +113,17 @@ services:
volumes:
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
# only reads it; the sso-manager bootstrap writes the OAuth creds. The
# entrypoint symlinks /config/proxy-secrets.js -> /app/conf/secrets.js.
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js.
- ./config:/config:ro
# Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local
# users, AND the auto-ssl Let's Encrypt certs survive container recreation.
- proxy-data:/data
- proxy-cache:/var/cache/nginx/proxy
- proxy-logs:/var/log/nginx
# OPTIONAL, for strict LDAPS trust (see README "Security notes"): mount
# the SSO's self-signed cert into the proxy read-only, then set
# ldap.tlsOptions.ca=<path-below> in ./config/proxy-secrets.js.
# - ldap-certs:/etc/ssl/sso-ldap-certs:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s
@@ -110,6 +131,32 @@ services:
retries: 3
start_period: 30s
# Optional SSH jump host. Only started when the `jump-host` compose profile
# is active — setup.sh exports COMPOSE_PROFILES=jump-host when
# CFG_JUMP_HOST_ENABLED=true. Authenticates users against the SSO's OpenLDAP,
# resolves reachable hosts from the directory API, and bridges SSH through.
jump-host:
profiles: ["jump-host"]
build:
context: ./jump-host
dockerfile: Dockerfile
args:
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
container_name: jump-host
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
ports:
- "${JUMP_SSH_PORT:-2222}:2222" # SSH front door
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
environment:
- NODE_ENV=production
volumes:
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
- jump-data:/var/lib/jump-host # generated host keys persist here
networks:
theta-net:
driver: bridge
@@ -120,4 +167,5 @@ volumes:
sso-data:
proxy-data:
proxy-cache:
proxy-logs:
proxy-logs:
jump-data:
+36 -4
View File
@@ -1,9 +1,41 @@
title: theta-env
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses
theme: jekyll-theme-cayman
show_downloads: true
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses.
url: "https://theta42.github.io"
baseurl: "/theta-env"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github:
repository_url: https://github.com/theta42/theta-env
zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz
repository_name: theta42/theta-env
repository_name: theta42/theta-env
nav:
- title: Home
page: /
icon: fa-house
- title: Quickstart
page: /quickstart.html
icon: fa-rocket
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Standalone
page: /standalone.html
icon: fa-puzzle-piece
- title: Changelog
url: https://github.com/theta42/theta-env/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+36 -12
View File
@@ -1,6 +1,7 @@
---
layout: default
title: Architecture
description: How theta-env composes the SSO Manager and proxy submodules — the OIDC/LDAP wiring setup.sh generates from one domain.
---
# Architecture
@@ -11,6 +12,8 @@ theta-env is a **composition** repo: it builds the two existing projects from
their git submodules and adds the glue that wires them together. It does not
fork or patch them — both projects work unchanged on their own.
---
## The three repos
| Repo | Role |
@@ -22,6 +25,8 @@ fork or patch them — both projects work unchanged on their own.
The two projects are pinned as **git submodules**. `git clone --recursive`
fetches all three in one step; `git submodule update --remote` bumps them.
---
## The two containers
```
@@ -68,6 +73,8 @@ redis instances is the no-source-patch path and is fine at this scale.
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
---
## The first-run bootstrap
`./setup.sh` orchestrates first-run wiring; `bootstrap/bootstrap.js` does the
@@ -95,28 +102,41 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret`
matching an existing client, they are kept; if the client exists but the file
has no usable secret, the secret is rotated and written back.
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint symlinks
`./config/proxy-secrets.js` to `/app/conf/secrets.js`, so `@simpleworkjs/conf`
(≥1.1.0) reads the OAuth creds + LDAP bind creds from the file.
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint points
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf`
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file.
7. **Register `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy**
`setup.sh` runs a short script inside the proxy container that calls its
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
than the proxy's own HTTP API, since no authenticated session exists yet at
this point in the run. The proxy routes every hostname purely off a Host
record (`ops/nginx_conf/proxy.conf` has no default/self route), so without
this step neither URL resolves to anything. `<SSO_HOST>` targets
`sso-manager:3001` (the Docker service), `<PROXY_HOST>` targets
`127.0.0.1:3000` (the proxy's own management app, same container). Both
are created with `sso_enabled: false` — each app already gates its own
login, and SSO-gating the SSO's own login page would be circular. Skips a
host that already exists, so re-running `setup.sh` is a no-op here.
`setup.sh` then prints the first-admin login + the public URLs.
### How config reaches the apps (no `.env`)
All config and secrets live in `./config/` (gitignored, bind-mounted). Each
entrypoint symlinks its file to `/app/conf/secrets.js` early, before the app
starts:
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0)
at its file early, before the app starts:
```
./config/sso-secrets.js -> sso-manager:/app/conf/secrets.js (./config RW)
./config/proxy-secrets.js -> proxy:/app/conf/secrets.js (./config RO)
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW)
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
```
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → conf/secrets.js → app_*
env`, where **env beats `secrets.js`**. So compose passes **no `app_*` env vars**
(only `NODE_ENV`, `NODE_PORT`) — that makes `secrets.js` authoritative. The SSO
entrypoint reads the few values it needs at startup (LDAP base DN, admin
password, JWT secret, cert CN) from `secrets.js` via an in-container `node` call.
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_*
env`, where **env beats the secrets file**. So compose passes **no `app_*` env
vars** (only `NODE_ENV`, `NODE_PORT`) — that makes the secrets file
authoritative. The SSO entrypoint reads the few values it needs at startup
(LDAP base DN, admin password, JWT secret, cert CN) from `sso-secrets.js` via
an in-container `node` call.
### Why not `require` the SSO's internal models?
@@ -127,6 +147,8 @@ admin creds from `./config/sso-secrets.js` sidesteps that entirely, and going
through the HTTP API for the OAuth client validates the whole admin login path
end-to-end.
---
## Idempotency
Re-running `./setup.sh` converges to `./config/`:
@@ -139,6 +161,8 @@ Re-running `./setup.sh` converges to `./config/`:
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
compose down`, or after restoring from backup.
---
## Backups and restore
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site — shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+52 -88
View File
@@ -1,105 +1,69 @@
---
layout: default
title: Home
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. Wires together a self-hosted identity provider and a reverse proxy with one setup.sh.
---
# theta-env
A single repo that runs the whole theta42 identity + access stack
[SSO Manager](https://github.com/theta42/sso-manager-node) (OIDC provider + LDAP)
and the [theta42/proxy](https://github.com/theta42/proxy) (OIDC-protected reverse
proxy) — together, with **one command**, for home labs and small businesses.
The whole theta42 identity + access stack in one repo, brought up with a
single command — for home labs and small businesses.
It exists for people whose needs are met by these two projects and who want to
run them "very simply." Each project still works **standalone**; this repo just
wires them together and automates the first-run glue.
It wires together two projects that already work on their own —
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
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`.
## Quick start
## Screenshots
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>
*(click either screenshot to view full size)*
## Why this over running them separately
Each project works standalone, but they only become useful together once the
proxy is registered as an OIDC client of the SSO *and* pointed at the SSO's
LDAP directory — and the domain has to match across half a dozen config
fields, or logins silently fail. Doing that by hand is fiddly. `setup.sh`
asks for your domain once, generates both apps' config with it filled in
everywhere, registers the proxy as an OIDC client automatically, and
snapshots state before every rebuild.
## What you get
- **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.
- **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.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
## Get it
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
./setup.sh # generates ./config/ the first time — edit it, then re-run
./setup.sh # builds + bootstraps + starts the stack
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
./setup.sh
```
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
time to converge the stack to `./config/`.
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run
any time to converge the stack to `./config/`. For the full config reference,
architecture, and running each project standalone, see the
**[GitHub repository](https://github.com/theta42/theta-env)**.
See the [Quickstart Guide](quickstart.html) for a walkthrough of `./config/` and
what `setup.sh` does, [Architecture](architecture.html) for how the pieces fit
together, and [Standalone](standalone.html) for running each project on its own.
## Related projects
## What you get
- **SSO Manager** at `https://<SSO_HOST>` — log in as your first admin to manage
users, groups, and OAuth clients. Fronted by the proxy under TLS.
- **Proxy** at `https://<PROXY_HOST>` — add the Host records you want to protect
with OIDC login.
- **LDAPS** at `ldaps://<host>:636` — legacy apps can bind directly (admin or
the read-only `cn=ldapclient` service account the bootstrap creates).
- **API tokens** — both apps let any logged-in user mint self-service personal
access tokens (`Authorization: Bearer sso_…` / `prx_…`) to drive the management
API from scripts/CI without a browser session. A token authenticates as its
creator (carrying their permissions); mint/rotate/revoke under **API Tokens**
in each UI. See each submodule's DEPLOYMENT for the details.
## The `./config/` values you must set
All config and secrets live in `./config/sso-secrets.js` +
`./config/proxy-secrets.js` (gitignored), generated by the first `./setup.sh`.
There is **no `.env`**. Set at least these in `./config/sso-secrets.js`:
| Key (in `sso-secrets.js`) | What it is |
|-----|------------|
| `stack.ldapBaseDn` | Directory base, e.g. `dc=lab,dc=local`. |
| `ldap.bindPassword` | LDAP root password (generated). **Back it up.** |
| `oauth.jwtSecret` | Signs the SSO's tokens (generated). **Back it up.** |
| `stack.ssoHost` | Public hostname the proxy serves the SSO UI at. |
| `stack.proxyHost` | Public hostname the proxy serves its own mgmt UI at. |
| `bootstrap.adminUid` / `bootstrap.adminPass` | Your first admin login. |
See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy
OIDC/LDAP/auth, …).
## Architecture
```
┌──────────────────────────────────────────────┐
│ your browser / apps │
└───────────────┬──────────────────────────────┘
│ https
┌─────────▼─────────┐
│ proxy │ OpenResty :80/:443/:4443
│ (OIDC + LDAP) │ mgmt app :3000 (localhost)
└─────────┬─────────┘ bundled redis
┌─────────────┼──────────────────────┐
│ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo
▼ ▼ │
┌──────────────────────────┐ │
│ sso-manager │◄────────────────┘
│ OIDC provider + OpenLDAP │ bundled redis
│ web UI :3001 (localhost) │
│ ldaps :636 (LAN clients) │
└───────────────────────────┘
```
The proxy is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). See [Architecture](architecture.html) for the full
diagram + the first-run bootstrap flow.
## Documentation
- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `setup.sh`.
- [Architecture](architecture.html) — the 3-repo + submodule + 2-container
design, and how the bootstrap wires the proxy into a fresh SSO.
- [Standalone](standalone.html) — running SSO Manager or the proxy on its own.
## Community
- [GitHub Repository](https://github.com/theta42/theta-env)
- [Issue Tracker](https://github.com/theta42/theta-env/issues)
## License
MIT License — see the repository for details.
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it.
+33 -6
View File
@@ -1,12 +1,15 @@
---
layout: default
title: Quickstart
description: Step-by-step first run for theta-env — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
---
# Quickstart Guide
[← Back to Home](index.html)
---
## Prerequisites
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
@@ -18,6 +21,8 @@ title: Quickstart
certs; otherwise the proxy serves a self-signed fallback (browsers warn —
expected for LAN use).
---
## 1. Clone
```bash
@@ -32,24 +37,29 @@ step. If you forgot it:
git submodule update --init --recursive
```
---
## 2. Configure `setup.env` (enter your domain once)
```bash
cp setup.env.example setup.env
$EDITOR setup.env # set CFG_BASE_DN to your domain, as a base DN
$EDITOR setup.env # set CFG_DOMAIN to your domain
```
Your domain is entered **once**, as the LDAP base DN. The SSO/proxy hostnames
default to `sso.<domain>` / `proxy.<domain>`, derived from it, so for most setups
`CFG_BASE_DN` is the only value you set:
Your domain is entered **once**, as a plain DNS domain. The SSO/proxy
hostnames default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN
is built from it (any number of labels works — a domain like
`myhost.duckdns.org` becomes `dc=myhost,dc=duckdns,dc=org`), so for most
setups `CFG_DOMAIN` is the only value you set:
| `setup.env` key | Example | Notes |
|-----|---------|-------|
| `CFG_BASE_DN` | `dc=lab,dc=local` | your directory base — **required** |
| `CFG_DOMAIN` | `lab.local` | your domain — **required** |
| `CFG_SSO_HOST` | `sso.lab.local` | optional, defaults to `sso.<domain>` |
| `CFG_PROXY_HOST` | `proxy.lab.local` | optional, defaults to `proxy.<domain>` |
| `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 |
`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
@@ -63,6 +73,8 @@ file shape.
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
> secrets — no need to write a `setup.env`.
---
## 3. Run
```bash
@@ -81,11 +93,16 @@ What happens:
service account, your first admin, and the proxy's OAuth client, and writes
the generated client id + secret into `./config/proxy-secrets.js`.
4. Builds + starts **proxy**, waits for `/health`.
5. Prints your first-admin login + the public URLs.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy —
every hostname the proxy serves, including its own UI and the SSO's,
needs one of these or it 404s. Idempotent.
6. Prints your first-admin login + the public URLs.
The first run builds two Docker images (a few minutes). Subsequent runs are
fast.
---
## 4. Point DNS at the host
`stack.ssoHost` and `stack.proxyHost` (from `./config/sso-secrets.js`) must
@@ -98,6 +115,8 @@ echo "127.0.0.1 sso.lab.local proxy.lab.local" | sudo tee -a /etc/hosts
(The proxy needs port 80 reachable for Let's Encrypt; on a LAN without that it
serves a self-signed cert — browsers will warn, which is fine for home-lab use.)
---
## 5. Log in
Open `https://<SSO_HOST>` and log in as your bootstrap admin
@@ -110,6 +129,8 @@ it). Add the Host records you want to protect with OIDC.
First-run fallbacks (if DNS/TLS isn't ready yet): SSO UI at
`http://127.0.0.1:3001`, proxy UI at `http://127.0.0.1:3000`.
---
## Re-running
`./setup.sh` is **idempotent** — safe to re-run after editing `./config/`, after
@@ -118,6 +139,8 @@ then converges the stack to your `./config/` values (LDAP service account + admi
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
already holds its creds).
---
## Direct LDAP for legacy apps
Legacy apps bind LDAP directly over LDAPS:
@@ -131,6 +154,8 @@ ldapsearch -x -H ldaps://<host>:636 \
Use the `cn=ldapclient` service account (read-only, the bootstrap created it)
or the admin DN. Use LDAPS (636), not plain LDAP.
---
## Backups and restore
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to `./backups/<ts>/`
@@ -145,6 +170,8 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "<base>" > backup-$(date +%F).ldif
```
---
## Next steps
- Add users / groups in the SSO UI.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
+42 -8
View File
@@ -1,6 +1,7 @@
---
layout: default
title: Standalone
description: Running the SSO Manager or the proxy on their own, without theta-env's orchestration.
---
# Running each project standalone
@@ -11,6 +12,8 @@ theta-env composes the two projects but doesn't fork them — both work on their
own. The submodules in this repo are normal clones; you can also clone them
directly from GitHub.
---
## SSO Manager alone
The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
@@ -22,18 +25,18 @@ mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build
```
The entrypoint symlinks `config/sso-secrets.js` to `nodejs/conf/secrets.js` so
The entrypoint points the `CONF_SECRETS` env var at `config/sso-secrets.js` so
`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and
the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass
**no `app_*` env** — env beats `secrets.js`, so `app_*` would silently override
your file.
**no `app_*` env** — env beats the secrets file, so `app_*` would silently
override your file.
- Web UI: `http://localhost:3001`
- Health: `http://localhost:3001/health`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAPS: `ldaps://<host>:636`
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
Requires `@simpleworkjs/conf` >= 1.2.0. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal
@@ -46,6 +49,8 @@ sudo systemctl enable --now sso-manager
Idempotent — re-run to update. See the SSO Manager
[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
---
## Proxy alone
The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
@@ -57,11 +62,11 @@ mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build
```
The entrypoint symlinks `config/proxy-secrets.js` to `nodejs/conf/secrets.js` so
`@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
The entrypoint points the `CONF_SECRETS` env var at `config/proxy-secrets.js`
so `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and
`auth` (admin groups/users). Pass **no `app_*` env** — env beats `secrets.js`,
so `app_*` would silently override your file.
`auth` (admin groups/users). Pass **no `app_*` env** — env beats the secrets
file, so `app_*` would silently override your file.
- Proxy (public, auto-SSL): `https://<host>/`
- Mgmt UI / API: `http://127.0.0.1:3000/`
@@ -70,6 +75,33 @@ so `app_*` would silently override your file.
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
### The `auth.adminUsers` anti-lockout account
Both `setup.sh` and `config.example/proxy-secrets.js.example` write
`auth.adminUsers: ['proxyadmin2']` into `proxy-secrets.js`. This is a
**local, config-driven admin bypass** — the proxy grants full admin rights to
any logged-in OIDC user whose username (the `preferred_username` claim from
the SSO) matches an entry in `auth.adminUsers`, regardless of their LDAP group
membership (see `proxy/nodejs/utils/roles.js`, `resolveEffective()`). It exists
so an operator can't lock themselves out of the proxy mgmt UI if the SSO's
`app_sso_admin` group is ever misconfigured, deleted, or otherwise broken.
It is **not** derived from any `setup.env` value, and it does **not** create a
user by itself — the name is only a username match. To actually use the
bypass, create a user with uid `proxyadmin2` in the SSO (it does not need to
be in `app_sso_admin` or any other group) and log in through the proxy as that
user.
To change or disable it, edit `auth.adminUsers` directly in
`./config/proxy-secrets.js` after the first `./setup.sh` run (re-running
`setup.sh` will not overwrite an existing `proxy-secrets.js`):
- **Rename** it to a less guessable username: `adminUsers: ['your-break-glass-uid']`.
- **Add more** anti-lockout accounts: `adminUsers: ['proxyadmin2', 'another-admin']`.
- **Disable** it entirely: `adminUsers: []` (global admin then comes only from
`auth.adminGroups` membership — make sure at least one real admin group is
reachable before doing this).
### Bare metal
```bash
@@ -80,6 +112,8 @@ See the proxy
[Docker guide](https://theta42.github.io/proxy/docker.html) /
[installation guide](https://theta42.github.io/proxy/installation.html).
---
## Mixing and matching
theta-env isn't required to use the two together — the four wiring steps are
Submodule
+1
Submodule jump-host added at 6e6f42e891
+1
View File
@@ -0,0 +1 @@
https://github.com/theta42/theta-env/pull/75
+1 -1
Submodule proxy updated: 3df7d8c5cb...60dbfe5b9b
+57 -11
View File
@@ -7,29 +7,59 @@
# (edit them directly; setup.env is ignored on later runs).
#
# cp setup.env.example setup.env
# $EDITOR setup.env # set CFG_BASE_DN below to your domain
# $EDITOR setup.env # set CFG_DOMAIN below to your domain
# ./setup.sh # generates ./config/ and builds the stack
#
# Copying this file to setup.env (gitignored) keeps your domain out of git.
# ─────────────────────────────────────────────────────────────────────────────
# Your domain, as an LDAP base DN. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED.
# Everything else derives from it: the SSO/proxy hostnames default to
# sso.<domain> / proxy.<domain>, and the LDAP DNs are cn=admin,<dn>,
# ou=people,<dn>, ou=groups,<dn>. Required — setup.sh refuses to run without it.
CFG_BASE_DN=dc=example,dc=com
# Your domain. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED. Everything else
# derives from it: the SSO/proxy hostnames default to sso.<domain> /
# proxy.<domain>, and the LDAP base DN is built from it (example.com becomes
# dc=example,dc=com; a 3-label domain like myhost.duckdns.org becomes
# dc=myhost,dc=duckdns,dc=org — any number of labels works). Required —
# setup.sh refuses to run without it.
CFG_DOMAIN=example.com
# Site name for the SSO directory — the root node this stack registers itself
# under on the Directory page, and the default "Location (Site)" that Linux
# hosts joined via ldap-client attach to (parent slug: site_<name>).
# Optional — defaults to "local".
#CFG_SITE_NAME=local
# Public hostnames. Optional — default to sso.<domain> / proxy.<domain> derived
# from CFG_BASE_DN above. Uncomment and set only if your hostnames differ
# from CFG_DOMAIN above. Uncomment and set only if your hostnames differ
# (e.g. a different subdomain, or the domain isn't the bare apex):
#CFG_SSO_HOST=sso.example.com
#CFG_PROXY_HOST=proxy.example.com
# ── Optional SSH jump host ───────────────────────────────────────────────────
# Enable the theta42/jump-host component: a public SSH jump host that
# authenticates users against the directory and bridges them to downstream
# hosts (ssh uid_-_target@jump, or an interactive picker). Off by default.
# When true, setup.sh clones/builds the jump-host submodule, the bootstrap
# mints its directory API token + writes ./config/jump-secrets.js, and it's
# registered in the proxy + directory. See jump-host's README for the LDAP
# write-ACL note (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST_ENABLED=false
#CFG_JUMP_HOST=jump.example.com # defaults to jump.<domain>
#JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default)
# Advanced: override the derived LDAP base DN directly (e.g. to namespace
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
#CFG_BASE_DN=dc=example,dc=com
# Optional — sensible defaults if left blank:
#CFG_ORG=SSO Manager # app display name + outbound email org
#CFG_ADMIN_UID=admin # initial SSO admin username
#CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost>
#CFG_LDAP_CERT_CN= # LDAP TLS cert CN; empty -> defaults to the domain
#
# Hostname advertised on the SSO /integrations page for direct LDAPS binds.
# Leave blank to derive it from the public SSO host (same as oauth.issuer).
# Recommended: set an internal-only name like 'ldap.internal.example.com' or
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
#CFG_LDAPS_HOST=
# Optional SMTP (outbound email from the SSO app). Leave blank to disable:
#CFG_SMTP_HOST=smtp.example.com
@@ -39,7 +69,23 @@ CFG_BASE_DN=dc=example,dc=com
#CFG_SMTP_FROM=SSO Manager <noreply@example.com>
# ── DO NOT put secrets here ──────────────────────────────────────────────────
# The LDAP admin password, JWT secret, admin password, and LDAP service-account
# password are GENERATED (random) into ./config/sso-secrets.js on first run.
# Change them later by editing ./config/sso-secrets.js directly. Do NOT set
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS here.
# The LDAP admin password, JWT secret, admin password, LDAP service-account
# password, and the proxy's local admin password are all GENERATED (random)
# into ./config/sso-secrets.js + ./config/proxy-secrets.js on first run.
# Change them later by editing those files directly (the proxy's local admin
# password is the exception — see ./config/proxy-secrets.js's auth.localAdminPass
# comment for how to actually change it after the account exists). Do NOT set
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
# CFG_PROXY_ADMIN_PASS here.
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
# If deploying this stack across multiple physical sites to provide local HA
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
# This requires assigning a unique ID to each site and listing the LDAPS URLs
# of all OTHER sites in the cluster.
#
# Each site MUST have a unique LDAP_SERVER_ID (e.g. 1, 2, 3).
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
# Example for Site 1:
#LDAP_SERVER_ID=1
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
+289 -33
View File
@@ -3,26 +3,36 @@
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
#
# git clone --recursive <theta-env> && cd theta-env
# cp setup.env.example setup.env # set CFG_BASE_DN to your domain (once)
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once)
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
#
# Idempotent: safe to re-run. It manages config in a bind-mounted ./config/
# directory (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots
# state before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# Idempotent: safe to re-run. It pulls its own latest version, updates the two
# submodules, manages config in a bind-mounted ./config/ directory
# (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots state
# before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# converges the LDAP service account / first admin / OAuth client to the ./config
# values and writes the generated OAuth client creds into proxy-secrets.js),
# then starts the proxy.
# then starts the proxy and registers the SSO's + proxy's own hostnames as
# Host records in it (otherwise the proxy has no route for either). A single
# `./setup.sh` run is enough to bring an existing deployment fully up to date —
# no manual `git pull` needed first.
#
# What it does, in order:
# 0. Pull theta-env's own latest commit (fast-forward only) and, if it
# moved, re-exec so the rest of this run uses the new script. Never
# blocks the run — skips silently with no upstream, warns and continues
# on any other pull failure (offline, local changes). Skip with
# SKIP_SELF_UPDATE=1.
# 1. Update the git submodules to the latest of their tracked remote branch
# (so each run builds the newest sso-manager-node + proxy). Skip with
# SKIP_SUBMODULE_UPDATE=1.
# 2. ensure_config: create ./config/sso-secrets.js + proxy-secrets.js if
# missing. On a fresh clone the domain/hosts are read from ./setup.env
# (the one place the domain is entered, as the LDAP base DN) and both
# files are generated with that domain filled in everywhere + random
# secrets, then the run proceeds to build (no edit-and-re-run step). On
# (the one place the domain is entered, as a plain DNS domain — the LDAP
# base DN is derived from it) and both files are generated with that
# domain filled in everywhere + random secrets, then the run proceeds to
# build (no edit-and-re-run step). On
# an existing deployment with .env/proxy.env, the secrets are migrated
# (preserved) into ./config. If ./config already exists it is left
# untouched (the operator owns it; setup.env is ignored).
@@ -33,9 +43,17 @@
# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js
# -> creates/updates the LDAP service account, first admin, OAuth client;
# writes the OAuth client creds into ./config/proxy-secrets.js; prints
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout.
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. Also seeds
# the SSO directory with the stack's own resources (site -> host ->
# SSO Manager + Proxy services, with the proxy's OAuth client linked
# under its service) so the Directory page is populated out of the
# box. Idempotent — existing slugs are operator-owned and left alone.
# 6. docker compose up -d --build proxy; wait for /health.
# 7. Print the first-admin login + the public URLs.
# 7. Register <SSO_HOST> and <PROXY_HOST> as Host records in the proxy (via
# `docker compose exec proxy node`, calling the proxy's Host model
# directly) so the proxy actually routes those hostnames somewhere —
# nothing else creates them. Idempotent; skips a host that already exists.
# 8. Print the first-admin login + the public URLs.
#
# Requires: git, docker + docker compose (v1 standalone or v2 plugin).
@@ -106,15 +124,97 @@ parse_kv_file() {
done < "$file"
}
# ── 1. Update submodules to latest, verify build contexts ─────────────────────
# ── 0. Self-update: pull theta-env itself, then restart with the new version ──
# Step 1 below only refreshes the proxy/sso-manager-node submodules — it never
# updates setup.sh or this repo's own files. Pull the current branch's
# upstream (fast-forward only) before anything else, and if it moved, re-exec
# so the rest of THIS run uses the freshly-pulled script rather than the copy
# already read into memory. Never blocks the run: skips silently if this
# isn't a git checkout, is on a detached HEAD, or has no upstream configured;
# warns (but continues on the current checkout) if the pull fails for any
# other reason (offline, local changes that prevent a fast-forward). Skip
# entirely with SKIP_SELF_UPDATE=1.
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_ENV_REEXECED:-0}" != "1" ]] \
&& command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
&& git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
then
BEFORE_REV="$(git rev-parse HEAD)"
BEFORE_VER="$(git describe --tags "$BEFORE_REV" 2>/dev/null || echo "${BEFORE_REV:0:12}")"
if git pull --ff-only -q; then
AFTER_REV="$(git rev-parse HEAD)"
if [[ "$BEFORE_REV" != "$AFTER_REV" ]]; then
AFTER_VER="$(git describe --tags "$AFTER_REV" 2>/dev/null || echo "${AFTER_REV:0:12}")"
info "Updated theta-env (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
THETA_ENV_REEXECED=1 exec "$0" "$@"
fi
else
warn "Could not fast-forward theta-env to the latest upstream (offline, or local changes) — continuing with the current checkout."
fi
fi
# ── Optional jump host: resolve the enable flag early ─────────────────────────
# CFG_JUMP_HOST_ENABLED gates the optional SSH jump host (a third submodule).
# Read it from the environment or ./setup.env now (before the submodule loop
# and the compose steps) so every run knows whether to build/start it. The
# authoritative CFG_* for secrets are still resolved in ensure_config; this is
# only the on/off switch + its hostname.
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
JUMP_ENABLED=0
case "${CFG_JUMP_HOST_ENABLED:-}" in 1|true|TRUE|yes|YES) JUMP_ENABLED=1 ;; esac
export CFG_JUMP_HOST_ENABLED CFG_JUMP_HOST
# When enabled, activate the compose profile so `up`/`ps` include the service.
if [[ "$JUMP_ENABLED" == "1" ]]; then export COMPOSE_PROFILES="jump-host"; fi
# ── 1. Update submodules to their latest release tag, verify build contexts ───
# Submodules track release tags (vX.Y.Z), not the tip of master -- so
# "update" means "move to the newest tag", not "move to the newest commit".
# `git submodule update --init --recursive` (no --remote) only clones a
# missing submodule at its currently-pinned commit; it never advances it on
# its own, so the per-submodule tag resolution below is what actually moves
# proxy/sso-manager-node forward.
if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
if ! command -v git >/dev/null 2>&1; then
die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits."
fi
info "Updating submodules to latest (sso-manager-node, proxy)..."
if ! git submodule update --init --remote --recursive 2>&1; then
warn "git submodule update failed (offline?) — continuing with the currently checked-out code."
if ! git submodule update --init --recursive 2>&1; then
die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
fi
# jump-host is optional: only track/build it when enabled.
SUBMODULES=(sso-manager-node proxy)
[[ "$JUMP_ENABLED" == "1" ]] && SUBMODULES+=(jump-host)
info "Updating submodules to their latest release tag (${SUBMODULES[*]})..."
for sm in "${SUBMODULES[@]}"; do
[[ -d "$sm" ]] || continue
before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
# Prefer the exact tag the submodule is currently pinned to; fall back
# to a short commit hash if it's on an untagged commit (shouldn't
# normally happen -- this repo only ever pins tagged releases).
before_tag="$(git -C "$sm" describe --tags --exact-match "$before_rev" 2>/dev/null || echo "${before_rev:0:12}")"
if ! git -C "$sm" fetch --tags -q 2>&1; then
warn " ${sm}: could not fetch tags (offline?) — staying on ${before_tag}."
continue
fi
latest_tag="$(git -C "$sm" tag --list 'v*' --sort=-v:refname | head -n1)"
if [[ -z "$latest_tag" ]]; then
warn " ${sm}: no vX.Y.Z release tags found — staying on ${before_tag}."
continue
fi
if ! git -C "$sm" checkout -q "$latest_tag" 2>&1; then
warn " ${sm}: could not check out ${latest_tag} — staying on ${before_tag}."
continue
fi
after_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
if [[ "$before_rev" != "$after_rev" ]]; then
info " ${sm}: updated ${before_tag} -> ${latest_tag}"
else
info " ${sm}: already up to date (${latest_tag})"
fi
done
else
info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)."
fi
@@ -125,11 +225,22 @@ fi
|| die "proxy/Dockerfile missing. Run: git submodule update --init --recursive"
# ── 2. ensure_config ──────────────────────────────────────────────────────────
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar).
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar). Only used to
# read domain back out of a base DN set directly (advanced override, or an
# old setup.env / migrated .env) — the normal path is dn_from_domain below.
domain_from_dn() {
echo "$1" | sed 's/^dc=//; s/,dc=/./g'
}
# Derive an LDAP base DN from a DNS domain (foo.bar -> dc=foo,dc=bar). This is
# the normal path: operators enter a plain domain in setup.env (CFG_DOMAIN),
# and the base DN is built from it, however many labels it has (a DuckDNS
# domain like foo.duckdns.org becomes dc=foo,dc=duckdns,dc=org — LDAP doesn't
# care how many dc= components there are).
dn_from_domain() {
echo "dc=$1" | sed 's/\./,dc=/g'
}
# Write ./config/sso-secrets.js from the CFG_* shell vars.
write_sso_secrets() {
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
@@ -137,7 +248,7 @@ write_sso_secrets() {
cat > "$CONFIG_DIR/sso-secrets.js" <<SSOEOF
'use strict';
// Generated by setup.sh. Edit freely; re-run ./setup.sh to apply.
// The SSO app reads this via @simpleworkjs/conf (symlinked to conf/secrets.js).
// The SSO app reads this via @simpleworkjs/conf (CONF_SECRETS env var).
// The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by
// the orchestrator). Back this file up off-host — it holds all SSO secrets.
@@ -149,6 +260,8 @@ module.exports = {
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
userBase: $(js_str "ou=people,${dn}"),
groupBase: $(js_str "ou=groups,${dn}"),
ldapsHost: $(js_str "${CFG_LDAPS_HOST:-}"),
ldapsPort: 636,
},
smtp: {
host: $(js_str "${CFG_SMTP_HOST:-}"),
@@ -168,6 +281,7 @@ module.exports = {
stack: {
ldapBaseDn: $(js_str "$dn"),
ldapDomain: $(js_str "$domain"),
siteName: $(js_str "${CFG_SITE_NAME:-local}"),
ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"),
ssoHost: $(js_str "$CFG_SSO_HOST"),
proxyHost: $(js_str "$CFG_PROXY_HOST"),
@@ -188,8 +302,8 @@ write_proxy_secrets() {
local dn="$CFG_BASE_DN"
cat > "$CONFIG_DIR/proxy-secrets.js" <<PROXYEOF
'use strict';
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (symlinked
// to conf/secrets.js). clientId/clientSecret are filled in by the bootstrap
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (CONF_SECRETS
// env var). clientId/clientSecret are filled in by the bootstrap
// (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal
// serviceAccountPass in sso-secrets.js (the proxy binds as that account).
@@ -221,6 +335,10 @@ module.exports = {
adminGroups: ['app_sso_admin'],
adminUsers: ['proxyadmin2'],
groupRoleMap: {},
// Initial password for the local anti-lockout admin (proxyadmin2) —
// only read by the proxy the first time that account is created;
// changing it here later has no effect on an already-created account.
localAdminPass: $(js_str "$CFG_PROXY_ADMIN_PASS"),
},
stack: {
ssoHost: $(js_str "$CFG_SSO_HOST"),
@@ -237,8 +355,9 @@ ensure_config() {
fi
# First run: read the domain/hosts from ./setup.env — the ONE place the
# domain is entered, as the LDAP base DN (e.g. dc=718it,dc=biz). Hostnames
# default to sso.<domain> / proxy.<domain>, derived from it. setup.env is
# domain is entered (e.g. 718it.biz), as a plain DNS domain; the LDAP base
# DN is derived from it (dc=718it,dc=biz). Hostnames default to
# sso.<domain> / proxy.<domain>, also derived from it. setup.env is
# used ONLY on first run; once ./config/*.js exist they are operator-owned
# and setup.env is ignored. Falls back to legacy .env/proxy.env migration
# below for existing deployments.
@@ -253,18 +372,21 @@ ensure_config() {
# derivation block further down (no example.com placeholders here).
CFG_BASE_DN="${CFG_BASE_DN:-}"
CFG_DOMAIN="${CFG_DOMAIN:-}"
CFG_SITE_NAME="${CFG_SITE_NAME:-}"
CFG_ORG="${CFG_ORG:-}"
CFG_SSO_HOST="${CFG_SSO_HOST:-}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}"
CFG_JWT_SECRET="${CFG_JWT_SECRET:-}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
# ── One-time migration from .env / proxy.env (existing deployments) ──
# Preserve the operator's existing secrets so the running deployment keeps
@@ -286,6 +408,8 @@ ensure_config() {
CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}"
CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}"
CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}"
# .env has no legacy LDAPS_HOST key; this stays as set in setup.env/env.
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_SMTP_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}"
CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}"
CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}"
@@ -304,17 +428,23 @@ ensure_config() {
migrated=1
fi
# Derive everything from the base DN — the one domain value. No example.com
# defaults: a blank base DN means first-run setup hasn't been done yet.
[[ -n "$CFG_BASE_DN" ]] \
|| die "First run: 'cp setup.env.example setup.env', set CFG_BASE_DN to your domain (e.g. dc=718it,dc=biz), then re-run ./setup.sh"
CFG_DOMAIN="${CFG_DOMAIN:-$(domain_from_dn "$CFG_BASE_DN")}"
# Derive everything from the domain — the one value operators enter. No
# example.com defaults: a blank domain means first-run setup hasn't been
# done yet. CFG_BASE_DN can still be set directly (setup.env or a migrated
# .env) to override the derived DN or to read the domain back out of an
# old-style DN-first setup.env; if not, it's built from CFG_DOMAIN.
CFG_DOMAIN="${CFG_DOMAIN:-$([[ -n "$CFG_BASE_DN" ]] && domain_from_dn "$CFG_BASE_DN" || true)}"
[[ -n "$CFG_DOMAIN" ]] \
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
CFG_ORG="${CFG_ORG:-SSO Manager}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
# Random secrets (generated fresh unless sourced/migrated above). These do
@@ -323,6 +453,7 @@ ensure_config() {
CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}"
CFG_SVC_PASS="${CFG_SVC_PASS:-$(rand_hex 16)}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-$(rand_hex 16)}"
mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
write_sso_secrets
@@ -464,7 +595,7 @@ backup_before_rebuild() {
# Only prune real backup dirs — skip symlinks (a stray symlink could
# point rm at an arbitrary tree) and non-dir entries.
[[ -d "$BACKUP_DIR/$old" && ! -L "$BACKUP_DIR/$old" ]] || continue
rm -rf "$BACKUP_DIR/$old" || true
rm -rf "${BACKUP_DIR:?}/$old" || true
removed=$((removed + 1))
done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | sort -r | tail -n +$((keep + 1)))
[[ "$removed" -gt 0 ]] && info " pruned $removed old backup(s) (keeping $keep)."
@@ -473,6 +604,13 @@ backup_before_rebuild() {
backup_before_rebuild
# ── 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
# hash from inside the Docker build context. Resolve it on the host (where
# the submodule DOES resolve correctly) and pass it in as a build arg; see
# 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
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build sso-manager
@@ -493,13 +631,14 @@ done
read_config_kv() {
"${COMPOSE[@]}" exec -T sso-manager node -e '
const c = require("/config/sso-secrets.js");
let p = {};
try { p = require("/config/proxy-secrets.js"); } catch (_) {}
const o = {
SSO_HOST: (c.stack && c.stack.ssoHost) || "",
PROXY_HOST: (c.stack && c.stack.proxyHost) || "",
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
ORG_NAME: c.name || "",
ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "",
ADMIN_PASS: (c.bootstrap && c.bootstrap.adminPass) || "",
};
for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k]));
' 2>/dev/null
@@ -509,7 +648,6 @@ cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
SSO_HOST="$(cfgval SSO_HOST)"
PROXY_HOST="$(cfgval PROXY_HOST)"
ADMIN_UID="$(cfgval ADMIN_UID)"
ADMIN_PASS="$(cfgval ADMIN_PASS)"
info "Stack config:"
info " SSO host: https://${SSO_HOST}"
@@ -520,12 +658,31 @@ info " Admin uid: ${ADMIN_UID}"
# The bootstrap reads its inputs from /config/*.js (not env) and writes the
# generated OAuth client creds back into /config/proxy-secrets.js. No -e flags.
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/bootstrap.js) \
# Host facts for the directory seed — collected HERE (on the host; inside the
# container hostname/uname describe the container, not the machine). Same
# collection as ldap-client/index.sh so stack hosts and ldap-client-joined
# hosts carry identical metadata. All best-effort: a missing tool just leaves
# the field blank.
STACK_HOST_NAME="$(hostname 2>/dev/null || true)"
STACK_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
_iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)"
STACK_HOST_MAC=""
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
-e STACK_HOST_IP="$STACK_HOST_IP" \
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
-e STACK_HOST_OS="$STACK_HOST_OS" \
-e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \
-e CFG_JUMP_HOST_ENABLED="${CFG_JUMP_HOST_ENABLED:-}" \
-e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \
sso-manager node /bootstrap/bootstrap.js) \
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}"
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
CLIENT_ID=$(getval CLIENT_ID)
CLIENT_SECRET=$(getval CLIENT_SECRET)
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
@@ -536,6 +693,9 @@ else
fi
# ── 6. Start the proxy, wait for health ───────────────────────────────────────
# 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
info "Building + starting proxy (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build proxy
@@ -548,7 +708,92 @@ for i in $(seq 1 60); do
sleep 2
done
# ── 7. Summary ───────────────────────────────────────────────────────────────
# ── 7. Register the SSO + proxy UIs as Host records in the proxy ──────────────
# The proxy routes EVERY hostname it serves — including its own management UI
# and the SSO's UI — off a Host record (ops/nginx_conf/proxy.conf has no
# default/self route; targetinfo.lua does a lookup for every request, full
# stop). Nothing else creates these two, so without this step https://<SSO_HOST>
# and https://<PROXY_HOST> 404 on first run. sso_enabled is left false on both:
# each app gates its own login already, and SSO-gating the SSO's own login page
# would be circular. Idempotent — skips a host that already exists.
info "Registering ${SSO_HOST} and ${PROXY_HOST} with the proxy..."
HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF
const {Host} = require('/app/models').models;
async function ensureHost(host, ip, targetPort) {
try {
await Host.get(host);
console.log('SKIP ' + host + ' (already exists)');
} catch (error) {
if (error.name !== 'EntryNotFound') throw error;
await Host.create({
host: host,
ip: ip,
targetPort: targetPort,
forcessl: true,
targetssl: false,
sso_enabled: false,
created_by: 'setup.sh',
});
console.log('CREATED ' + host + ' -> ' + ip + ':' + targetPort);
}
}
(async () => {
try {
await ensureHost($(js_str "$SSO_HOST"), 'sso-manager', 3001);
await ensureHost($(js_str "$PROXY_HOST"), '127.0.0.1', 3000);
process.exit(0);
} catch (error) {
console.error('ERROR', error.message);
process.exit(1);
}
})();
NODEEOF
) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}"
echo "$HOSTS_OUT" | sed 's/^/[setup] /'
# ── 7b. Optional: build + start the SSH jump host ─────────────────────────────
# Enabled by CFG_JUMP_HOST_ENABLED. The bootstrap (step 5) already wrote
# ./config/jump-secrets.js (minted API token + LDAP admin bind). Build/start the
# service (compose profile 'jump-host' is active), wait for its web /health, and
# register its web UI hostname as a proxy Host so https://<JUMP_HOST> routes.
if [[ "$JUMP_ENABLED" == "1" ]]; then
JUMP_HOST="${CFG_JUMP_HOST:-jump.${SSO_HOST#sso.}}"
JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)"
export JUMP_GIT_COMMIT
info "Building + starting jump-host (optional; enabled via CFG_JUMP_HOST_ENABLED)..."
"${COMPOSE[@]}" up -d --build jump-host
info "Waiting for jump-host to be healthy..."
for i in $(seq 1 60); do
if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then
info "jump-host is healthy."; break
fi
if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi
sleep 2
done
info "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..."
JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF || true
const {Host} = require('/app/models').models;
(async () => {
try {
try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); }
catch (e) {
if (e.name !== 'EntryNotFound') throw e;
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: true, targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
}
process.exit(0);
} catch (error) { console.error('ERROR', error.message); process.exit(1); }
})();
NODEEOF
)
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
fi
# ── 8. Summary ───────────────────────────────────────────────────────────────
echo
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
echo
@@ -556,10 +801,21 @@ echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS
echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
echo " Proxy mgmt UI: https://${PROXY_HOST}"
echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}"
if [[ "$JUMP_ENABLED" == "1" ]]; then
echo " Jump host (SSH): ssh -p ${JUMP_SSH_PORT:-2222} <uid>@${JUMP_HOST:-jump.${SSO_HOST#sso.}} (TUI picker)"
echo " ssh -p ${JUMP_SSH_PORT:-2222} <uid>_-_<host>@${JUMP_HOST:-jump.${SSO_HOST#sso.}}"
echo " Jump host (web): https://${JUMP_HOST:-jump.${SSO_HOST#sso.}} (audit + metrics)"
fi
echo
echo " First admin login:"
echo " First admin login credentials are in ./config/sso-secrets.js:"
echo " user: ${ADMIN_UID}"
echo " pass: ${ADMIN_PASS}"
echo " pass: bootstrap.adminPass"
echo
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
echo " user: proxyadmin2"
echo " pass: auth.localAdminPass in ./config/proxy-secrets.js"
echo " (only shown when the account is first created; edit ./config/proxy-secrets.js"
echo " or use the proxy UI to change it afterward)"
echo
echo " Secrets live in ./config/ (sso-secrets.js + proxy-secrets.js). Back them"
echo " up off-host — ./setup.sh snapshots to ./backups/ before each rebuild."