Compare commits

...

129 Commits

Author SHA1 Message Date
wmantly f27ce70c5d fix: skip host self-registration when sso_token empty; roll up ldap-client v1.25.0 (v1.40.0)
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
ldap-client no longer POSTs an empty Bearer to /api/directory-admin/resources
(the misleading 'Invalid Credentials, login failed' during setup). Gitlink ->
ldap-client 68fcdb5 (v1.25.0).
2026-08-05 01:33:23 -04:00
wmantly 3354407f04 fix: publish plain LDAP (389) to the host so setup.sh can reach the directory (v1.39.0) (#165)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
docker-compose only published LDAPS (636); plain LDAP (389) was not mapped, so
the stack host's own enrollment (ldap://localhost:389) couldn't reach the LDAP
server. Now both 389 + 636 are published (bind 0.0.0.0; LDAP_BIND/LDAPS_BIND to
lock to host). README updated.
2026-08-05 00:15:12 -04:00
wmantly 84d7c96c17 fix: LDAP enrollment uses localhost (not the public domain); align SSH access groups; roll up ldap-client v1.24.0 (v1.38.0) (#164)
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 16s
- setup.sh: ldap_host defaults to localhost (the public sso.<domain> can't reach
  the 389/636 LDAP ports through NAT); overridable via CFG_LDAPS_HOST
- ldap.vars access groups + ldap-client sssd filter now reference the SSO group
  model (site_<loc>_hosts_access, site_<loc>_host_<host>_access, god_admin)
- GROUPS.md §5/§8 updated to the corrected naming
- gitlink: ldap-client ebaac18 (v1.24.0)
2026-08-04 23:30:04 -04:00
wmantly 72046a8b29 docs: group naming matches docs/GROUPS.md; roll up sso v1.27.0 (v1.37.0) (#163)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
- GROUPS.md: per-resource groups are {site}_{kind}_{name}_{level}; site carries god + site-wide only
- gitlink: sso-manager-node e8d0420 (v1.27.0)
2026-08-04 23:02:14 -04:00
wmantly 73e1cc807a fix: setup.sh ldap.vars re-run abort + drop app_super_admin; roll up sso v1.26.1 (v1.36.1) (#162)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 19s
- setup.sh: ldap.vars generation read CFG_* first-run vars (unset on re-run);
  now reads real values from sso-secrets.js, so LDAP enrollment works on re-runs
- generated ldap_access_groups now references god_admin (app_super_admin gone)
- gitlink: sso-manager-node 8db00f0 (v1.26.1)
2026-08-04 19:33:32 -04:00
wmantly a77aa8d2df feat: seed god_admin + docker plugin, fix ldap-client enrollment, roll up sso v1.26.0 + theta-agent v1.3.0 (v1.36.0) (#161)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 16s
- bootstrap: seed god_admin into the admin's groups; seed a docker-local discovery plugin
- setup.sh: generate ldap-client/ldap.vars from the stack config so LDAP enrollment works
- docs: GROUPS.md site-slug convention (verbatim, kind in resource slug)
- gitlinks: sso-manager-node 8a9de94 (v1.26.0), theta-agent 52379c2 (v1.3.0)
2026-08-04 19:10:32 -04:00
wmantly 0e78a9e282 Merge pull request #160 from theta42/release/v1.35.18
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 19s
chore: sync proxy/jump gitlinks (v1.35.18)
2026-08-04 16:54:41 -04:00
wmantly 59c5c66007 chore: sync proxy/jump gitlinks to version-tagged commits (v1.35.18)
proxy v1.33.0 + jump v1.18.0 had package.json synced to their tags; update the
gitlinks so a deploy reports matching versions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:53:38 -04:00
wmantly ec426680c3 Merge pull request #159 from theta42/release/v1.35.17
CI/CD / docker-push (push) Failing after 17s
CI/CD / build-theta-agent (push) Successful in 47s
docs: group & permission model + sso group model (v1.35.17)
2026-08-04 16:49:11 -04:00
wmantly 6fae96d977 chore: bump sso-manager-node gitlink to v1.25.0
sso v1.25.0 shipped the group & permission model + the v1.24.0 batch (Agents →
Directory, plugin modal rework, Vault restyle). Update the gitlink for the release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:48:16 -04:00
wmantly 292b67c334 docs: group & permission model spec + link (v1.35.17)
Add docs/GROUPS.md — the canonical Group & Permission Model (schema, inheritance
resolver, Directory-only management, multi-site, host-side SSSD mapping, migration)
— link it from the docs index, and note sso v1.25.0 in the changelog.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:04:30 -04:00
wmantly 72606bfc13 feat: seed theta-proxy + theta-jump as managed host resources (v1.35.16)
The bootstrap now creates theta-proxy and theta-jump as managed host-kind
resources in the Directory (matching the OAuth client identities), alongside
the existing stack host and its service entries, so a fresh install shows them
as first-class hosts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:27:28 -04:00
wmantly c28e53e505 Merge pull request #158 from theta42/fix/theta-agent-text-file-busy-v1.35.15
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
fix: stop theta-agent before overwriting binary (v1.35.15)
2026-08-04 00:34:43 -04:00
wmantly 7ae2472c62 fix: stop theta-agent before overwriting binary (v1.35.15)
cp into a running executable fails with 'Text file busy' on a re-install.
Stop the service before copying the prebuilt binary.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:32:33 -04:00
wmantly 0c4abd82be Merge pull request #157 from theta42/release/v1.35.14-token-lifecycle
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
2026-08-04 00:25:16 -04:00
wmantly c27e8c7867 feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
- theta-svc token role (periodic 768h): SSO/PROXY/JUMP_VAULT_TOKEN now minted
  through it; ensure_token renews periodic tokens on every setup.sh re-run and
  detects/revokes/re-mints valid-but-non-periodic tokens from older installs.
- bao-renewer sidecar (docker-compose): renews the three service tokens every
  12h while the stack runs.
- sso-app token role (periodic 768h) + sso-broker policy grants for
  auth/token/create/sso-app and renew/revoke/lookup-accessor.
- docs/secrets.md rewritten around the new lifecycle.
- Bump sso-manager-node gitlink to v1.23.0 (real vault-403 fix + app-token
  lifecycle).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:21:51 -04:00
wmantly 9750413178 Merge pull request #156 from theta42/release/v1.35.13-agents-page
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 18s
feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13)
2026-08-03 23:16:39 -04:00
wmantly c1a9d8f059 feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13) 2026-08-03 23:15:19 -04:00
wmantly 0edce57f80 Merge pull request #155 from theta42/fix/theta-agent-service-control-v1.35.12
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 18s
fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
2026-08-03 23:06:39 -04:00
wmantly 49b868fedb fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
setup.sh's 'full control' edit set service_control: true, but that field is a
[]string allowlist, so theta-agent failed YAML decode and crash-looped. Remove
the invalid edit; leave the operator's allowlist (or [] default = deny all).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:05:04 -04:00
wmantly f9af81983b Merge pull request #154 from theta42/fix/unseal-key-unbound-v1.35.11
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
fix: UNSEAL_KEY unbound variable in setup.sh (v1.35.11)
2026-08-03 22:45:27 -04:00
wmantly 5ef2493e17 chore: changelog for v1.35.11 2026-08-03 22:44:03 -04:00
wmantly 8535372123 fix: guard UNSEAL_KEY with ${UNSEAL_KEY:-} in setup.sh (v1.35.11)
On a re-run where OpenBao is already unsealed, the unseal block is skipped and
UNSEAL_KEY is never set; line 778 then referenced it under set -u and aborted
with 'UNSEAL_KEY: unbound variable'. Guard with ${UNSEAL_KEY:-} so the
VAULT_UNSEAL_KEY upsert is simply skipped when there's no key this run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:43:51 -04:00
wmantly ade9a41aed Merge pull request #153 from theta42/release/v1.35.10-reset-openbao-theta-agent
CI/CD / build-theta-agent (push) Successful in 47s
CI/CD / docker-push (push) Failing after 16s
feat: --reset-openbao + fix theta-agent install; bump sso to v1.21.0 (v1.35.10)
2026-08-03 22:26:35 -04:00
wmantly ce664f5cb9 feat: --reset-openbao + fix theta-agent install; bump sso to v1.21.0 (v1.35.10)
- Add --reset-openbao: full clean OpenBao reset (re-init store, flush the
  Redis vault-token cache) to clear stale policies/tokens causing recurring
  vault 403s.
- Fix theta-agent install: copy the prebuilt theta-agent-linux-amd64 from the
  submodule instead of a broken go build; write config to /etc/theta42/agent.yml
  (the path the agent reads), not /etc/theta/agent.yml.
- Bump sso-manager-node gitlink to v1.21.0 (shared secrets + durable vault 403 fix).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:24:48 -04:00
wmantly 647f5b846c Merge pull request #152 from theta42/fix/submodule-version-sync-v1.35.9
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 17s
fix: bump sso & proxy submodules to corrected version tags (v1.35.9)
2026-08-03 21:37:39 -04:00
wmantly fe08c2f8c7 fix: bump sso & proxy submodules to corrected version tags (v1.35.9)
The v1.20.2 / v1.32.0 release tags were created but their package.json
versions lagged (1.20.1 / 1.14.3), so the deployed apps' update-check
banner falsely reported a newer version. Repoint the sso-manager-node and
proxy gitlinks to the corrected commits and release v1.35.9.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 21:36:24 -04:00
wmantly 43c58e7ed5 Merge pull request #151 from theta42/fix/auto-reset-unseal-key-loss-v1.35.8
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
fix(setup): auto-reset OpenBao volume and re-initialize if unseal key is lost v1.35.8
2026-08-03 15:44:22 -04:00
wmantly 13d8a979c7 fix(setup): auto-reset OpenBao volume and re-initialize if unseal key is lost v1.35.8
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:43:35 -04:00
wmantly 16cbe46793 Merge pull request #150 from theta42/fix/restore-bao-init-from-backup-v1.35.7
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 17s
fix(setup): auto-restore bao-init.json from backups if missing from config/ v1.35.7
2026-08-03 15:42:35 -04:00
wmantly 40a1e7f64e fix(setup): auto-restore bao-init.json from backups if missing from config/ v1.35.7
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 15:42:01 -04:00
wmantly 45715b61ea Merge pull request #149 from theta42/fix/env-get-function-order-v1.35.6
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
fix(setup): move env_get helper function definition to top of setup.sh v1.35.6
2026-08-03 15:39:39 -04:00
wmantly 54ebb83bb1 fix(setup): move env_get helper function definition to top of setup.sh v1.35.6
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 15:39:05 -04:00
wmantly a67d972217 Merge pull request #148 from theta42/fix/setup-openbao-unseal-fallback-v1.35.5
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
fix(setup): fallback to .env VAULT_UNSEAL_KEY and VAULT_TOKEN if bao-init.json is missing v1.35.5
2026-08-03 15:36:46 -04:00
wmantly 875ea874b4 fix(setup): fallback to .env VAULT_UNSEAL_KEY and VAULT_TOKEN if bao-init.json is missing v1.35.5
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 12s
2026-08-03 15:36:22 -04:00
wmantly 119f21b821 Merge pull request #147 from theta42/bump/sso-v1.20.2-proxy-v1.32.0
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
bump(deps): update sso-manager-node to v1.20.2 and proxy to v1.32.0
2026-08-03 15:31:29 -04:00
wmantly 80275c42e4 bump(deps): update sso-manager-node to v1.20.2 and proxy to v1.32.0
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:30:56 -04:00
wmantly a30deae866 Merge pull request #146 from theta42/bump/theta-agent-v1.2.1
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
bump(theta-agent): update to v1.2.1 for automatic SSSD installation
2026-08-03 15:02:56 -04:00
wmantly dc9a7ff9c4 bump(theta-agent): update to v1.2.1 for automatic SSSD installation
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:02:14 -04:00
wmantly 7937d6cf92 Merge pull request #145 from theta42/fix/setup-unbound-var-v1.35.2
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 18s
fix(setup): export CFG_CREATE_ALL_HTTP default and add fail-safe parameter expansion v1.35.2
2026-08-03 14:59:07 -04:00
wmantly 21333de814 fix(setup): export CFG_CREATE_ALL_HTTP default and add fail-safe parameter expansion v1.35.2
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
2026-08-03 14:58:39 -04:00
wmantly b3bdebe1c9 Merge pull request #144 from theta42/release/v1.35.1
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 16s
release(theta-suite): v1.35.1
2026-08-03 14:02:55 -04:00
wmantly e6b318e28e release(theta-suite): v1.35.1 - Submodule updates, Directory, Conf layout & Jump host target filter
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 14:02:13 -04:00
wmantly 4d0b7f555e Merge pull request #143 from theta42/feature/v1.35.0-final-roll-up
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 18s
release: v1.35.0 final roll-up
2026-08-03 02:42:24 -04:00
wmantly 67b511f8d7 release: v1.35.0 - final roll up (sso v1.20.0, proxy v1.14.3, jump-host v1.17.1, theta-agent v1.2.0) 2026-08-03 02:41:57 -04:00
wmantly b8a8be9697 Merge pull request #142 from theta42/feature/v1.35.0-theta-suite-stack-update
release: v1.35.0 theta-suite stack roll-up
2026-08-03 02:22:39 -04:00
wmantly cd9c81cd92 release: v1.35.0 - roll up submodules (sso v1.20.0, proxy v1.14.3, jump-host v1.17.1, theta-agent v1.2.0) and setup fixes 2026-08-03 02:22:10 -04:00
wmantly c8c04440db Merge pull request #141 from theta42/release/v1.35.0-update
Release v1.35.0 - Bump sso-manager-node to v1.19.6
2026-08-02 23:03:49 -04:00
wmantly d53bdefc2a chore: Bump sso-manager-node to v1.19.6
Lint / Shellcheck setup.sh (push) Failing after 7s
Lint / Syntax check bootstrap.js (push) Successful in 14s
### Changed
- sso-manager-node: v1.18.0-26-gef2207e → v1.19.6 (4945dec)

### sso-manager-node v1.19.6 highlights
- Fixed navbar auth (Catalog/Vault now require login)
- Added docs/directory.md
- SMTP moved to UI-only configuration
- Added test email/SMS API endpoints
- Added non-interactive theta-agent config variables

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:55:30 -04:00
wmantly 542e5fd33f chore: Release v1.35.0 - Non-interactive theta-agent config
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
### Added
- Non-interactive theta-agent configuration via setup.env variables

### Changed
- setup.sh: Made theta-agent setup fully non-interactive

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:34:45 -04:00
wmantly 848f35fc5e chore: Add theta-agent configuration variables to setup.env
### Changed
- **setup.sh**: Made theta-agent installation and configuration non-interactive,
  controlled by CFG_THETA_AGENT_* environment variables.
- **setup.env.example**: Added documentation for:
  - CFG_THETA_AGENT_ENABLE (default: 1)
  - CFG_THETA_AGENT_LDAP_AUTH (default: 1)
  - CFG_THETA_AGENT_FULL_CONTROL (default: 1)

All options default to enabled for backwards compatibility.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:23:29 -04:00
wmantly 1d14fcee19 feat: install theta-agent on host and add CFG_CREATE_ALL_HTTP option (#139)
CI/CD / build-theta-agent (push) Successful in 39s
CI/CD / docker-push (push) Failing after 15s
* feat: install theta-agent on host and add CFG_CREATE_ALL_HTTP option

* docs: update changelog for 1.34.5
2026-08-02 20:04:44 -04:00
wmantly 30609de3e8 chore: release v1.34.4 (update sso-manager-node submodule for vault fix) (#138)
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 16s
2026-08-02 19:48:54 -04:00
wmantly 925ac027a6 Merge pull request #137 from theta42/release-v1.34.3
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 16s
chore: release v1.34.3
2026-08-02 19:09:51 -04:00
wmantly 3b769cf24a chore: release v1.34.3 (updates submodules, docs, bootstrap)
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-02 19:08:14 -04:00
wmantly 2785b861b3 Merge pull request #136 from theta42/update-submodules-2
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 15s
chore: update submodules to v1.19.4 and v1.14.2
2026-08-02 14:12:02 -04:00
wmantly c216ddd4e8 chore: update submodules to v1.19.4 and v1.14.2 2026-08-02 14:11:11 -04:00
wmantly 9c3cbb0ec2 Merge pull request #135 from theta42/update-submodules
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
chore: update submodules
2026-08-02 13:25:20 -04:00
wmantly f44c075ede chore: update sso-manager-node submodule 2026-08-02 13:24:14 -04:00
wmantly ac9f672bae Merge pull request #134 from theta42/fix/setup-echo-and-unbound
fix: setup.sh output bugs
2026-08-02 13:21:31 -04:00
wmantly 43b8307e54 docs: note docker compose v1 incompatibility 2026-08-02 13:13:50 -04:00
wmantly 9541c47470 fix: resolve jump-host naming bug and setup.sh secrets list bug 2026-08-02 12:56:55 -04:00
wmantly a40326778e fix: setup.sh color escape and unbound variable 2026-08-02 12:26:19 -04:00
wmantly 68a0ce4d12 Merge pull request #133 from theta42/chore/release-v1.34.0
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 19s
chore: release v1.34.0
2026-08-02 12:14:18 -04:00
wmantly 3cb5540b2d docs: release v1.34.0
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-02 12:13:24 -04:00
wmantly 4d745a9c1d Merge pull request #132 from theta42/fix/setup-redis-snapshot
fix: prevent pipefail abort when redis-cli fails on restarting container
2026-08-02 12:09:50 -04:00
wmantly 36b39dfcc7 fix: prevent pipefail abort when redis-cli fails on restarting container 2026-08-02 11:59:34 -04:00
wmantly 06a4081a50 Merge pull request #131 from theta42/release-pki
v1.9.0: PKI Certificates & Theta Agent C2
2026-08-02 11:37:49 -04:00
wmantly 2f793206ed chore: remove redundant submodule unit test jobs 2026-08-02 11:35:23 -04:00
wmantly f7e9c20f72 fix: ci submodule checkout and bump sso-manager-node 2026-08-02 11:24:45 -04:00
wmantly 5ee486e808 chore: update submodules for PKI cert and agent C2 releases 2026-08-02 01:54:54 -04:00
wmantly 475ae2c893 docs: remove all standalone deployment documentation 2026-08-02 00:51:25 -04:00
wmantly 2eb5bf5daa Merge pull request #130 from theta42/release-v1.33.0
Release v1.33.0
2026-08-02 00:39:29 -04:00
wmantly 35c8a51182 chore: bump submodules for OpenBao secret integration
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
CI/CD / test-sso-manager (push) Failing after 8s
CI/CD / test-jump-host (push) Failing after 8s
CI/CD / test-proxy (push) Failing after 7s
CI/CD / build-telemetry-agent (push) Failing after 8s
CI/CD / docker-push (push) Has been skipped
2026-08-02 00:38:49 -04:00
wmantly f3b951b780 chore: release v1.31.0
CI/CD / test-sso-manager (push) Failing after 9s
CI/CD / test-jump-host (push) Failing after 7s
CI/CD / test-proxy (push) Failing after 8s
CI/CD / build-telemetry-agent (push) Failing after 8s
CI/CD / docker-push (push) Has been skipped
2026-08-02 00:16:27 -04:00
wmantly 8f5ce71bda feat: Add CI/CD workflow, update docs, update submodules 2026-08-02 00:16:27 -04:00
wmantly 6de31aa5e0 Merge pull request #129 from theta42/release-v1.31.1
release v1.31.1: sso v1.17.2 + /vault policy fix (setup.sh)
2026-08-01 22:53:37 -04:00
wmantly 6c02e6c63e release: v1.31.1 — sso v1.17.2 + /vault policy fix (setup.sh)
- bump sso-manager-node submodule gitlink v1.17.1 -> v1.17.2
  (post-deploy fixes: auto-slug plugins, schedule dropdown, /profile
  rendering, plugin-edit persistence, nmap in image, SMS/TOS on /conf,
  sso-side /vault policy grants)
- setup.sh: add sso-admin list grant on secret/metadata (KV mount root)
  so the /vault secrets list no longer 403s for admins
- setup.sh: ensure_policy now always (re)writes the policy so policy
  edits apply on a re-run instead of stranding the old HCL
- CHANGELOG embeds the full sso v1.17.2 changelog

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:52:50 -04:00
wmantly e2e8143880 Merge pull request #128 from theta42/release-v1.31.0
v1.31.0: roll up submodules to latest (sso v1.17.1 + ldap-client v1.23.0)
2026-08-01 21:26:55 -04:00
wmantly aa01a5cc07 release: bump submodules to latest tags (v1.31.0)
sso-manager-node v1.16.1 -> v1.17.1 (plugin system v1.17.0 + /conf secret
masking v1.17.1). ldap-client v1.1.1 -> v1.23.0 (CHANGELOG-only, no code
change). proxy v1.13.1 + jump-host v1.14.1 already latest, unchanged.
Changelog embeds the full sso v1.17.0 + v1.17.1 release notes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:26:13 -04:00
wmantly 1185bb90b8 Merge pull request #127 from theta42/feature/plugin-secrets-policy
v1.30.1: grant sso-broker OpenBao access to secret/plugins/* (plugin-system prerequisite)
2026-08-01 20:50:20 -04:00
wmantly 403e66556c feat: grant sso-broker OpenBao access to secret/plugins/* (v1.30.1)
Prerequisite for the SSO Manager plugin system (shipped in sso-manager-node
v1.17.0). Adds secret/data/plugins/* (CRUD+list) + secret/metadata/plugins/*
(list/read/delete) to the sso-broker policy HCL so the SSO can store per-instance
plugin secrets in OpenBao instead of sso-secrets.js. ensure_policy is idempotent,
so re-running ./setup.sh grants the existing SSO_VAULT_TOKEN live.

Docs: secrets.md (Plugin secrets section + policy row), architecture.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 20:33:21 -04:00
wmantly 3287777b9b v1.30.0: rename theta-env -> theta-suite + docs rewrite + sso v1.16.1 (#126)
Rename the project to theta-suite (it is now an integrated suite of four
apps around a shared OpenBao secrets store, not a two-project env).
- theta-env -> theta-suite across the superproject: _config.yml (title +
  baseurl /theta-suite + repo URLs), README, setup.sh (incl. the
  THETA_SUITE_REEXECED self-update sentinel), docker-compose.yml,
  bootstrap.js, lint.yml, config.example/*, docs/robots.txt, all docs,
  this changelog.
- architecture.md rewritten: real 4-service + ldap-client topology, OpenBao
  secrets section, OpenBao-aware config flow; removed "two containers" /
  "three repos" / LDAP-"legacy" framing.
- index.md: integrated-suite framing + secrets/OpenBao + ldap-client.
- standalone.md + README: standalone reframed as advanced opt-in.
- sso-manager-node submodule -> v1.16.1 (401 fix on /conf and /vault).

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 18:46:10 -04:00
wmantly 5ef3e3fa8c v1.29.0: jump host is core + fix fresh-install setup.sh abort (#125)
Two fresh-install fixes and promote the SSH jump host from opt-in to core.

setup.sh: fix silent abort after "Minting per-app OpenBao tokens". env_get's
grep|cut pipeline returns non-zero under set -euo pipefail when .env exists
(created by the root VAULT_TOKEN env_upsert) but an app-token key is absent
(the normal first-run state); the unguarded existing assignment from env_get
then tripped set -e and killed the script before minting any token. env_get
now always returns 0 (|| true). Reproduced + verified under the exact condition.

jump host is no longer optional:
- docker-compose.yml: drop profiles jump-host from the jump-host service
  (always started); rename the opt-in test fixture profile jump-host to ldap-test.
- setup.sh: SUBMODULES always includes jump-host; build/start/register/summary
  no longer guarded by JUMP_ENABLED; drop the COMPOSE_PROFILES export.
- bootstrap.js: jump provisioning + directory record run unconditionally.
- setup.env.example/docs: drop optional/CFG_JUMP_HOST_ENABLED wording.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 13:48:28 -04:00
wmantly a1ea2d458e Merge pull request #124 from theta42/feature/openbao-secrets
v1.28.0: OpenBao as the central secrets store for the stack
2026-08-01 12:55:32 -04:00
wmantly f7df04c2f0 v1.28.0: OpenBao as the central secrets store for the stack
theta-env orchestration:
- setup.sh: idempotent OpenBao policies (sso-broker, sso-admin, proxy,
  jump-host), sso-broker token role (allowed_policies_glob user-*/app-*,
  24h), mint scoped SSO/PROXY/JUMP_VAULT_TOKEN (orphan, .env reuse),
  seed_app_conf seeds secret/{sso-manager,proxy,jump-host}/conf. Bootstrap
  exec passes root VAULT_ADDR/VAULT_TOKEN for seeding. Root token never
  reaches a service container. shellcheck -S warning clean.
- docker-compose.yml: VAULT_ADDR + VAULT_TOKEN env for sso/proxy/jump;
  proxy/jump depends_on openbao service_started.
- bootstrap/bootstrap.js: baoPut() writes generated OAuth creds to
  secret/proxy/conf + secret/jump-host/conf (OpenBao authoritative).
- docs/secrets.md (new): full secrets architecture. README + nav updated.

Submodule bumps:
- sso-manager-node -> v1.16.0 (OpenBao broker + vault UI + remediation)
- proxy -> v1.13.1 (via v1.13.0: OpenBao boot)
- jump-host -> v1.14.1 (via v1.14.0: OpenBao boot)
- ldap-client unchanged

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

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

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

jump-host and proxy pointers are deliberately unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:30:32 -04:00
wmantly cf1591cdaa Merge pull request #114 from theta42/release/1.20.0
Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
2026-07-30 12:08:25 -04:00
wmantly d4154cfec6 Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
- Bump jump-host, ldap-client, proxy, sso-manager-node submodules to their
  new tags (cross-app app_super_admin group, jump-host's app_jump_admin,
  ldap-client's SSH host access for super admins).
- setup.sh: new env_upsert helper persists SSO_GIT_COMMIT/PROXY_GIT_COMMIT/
  JUMP_GIT_COMMIT into ./.env (docker compose's auto-loaded env file) so an
  ad-hoc single-service rebuild outside a full setup.sh run still bakes the
  right commit hash instead of "unknown".
2026-07-30 12:07:12 -04:00
wmantly 6e411160a6 Merge pull request #113 from theta42/release/1.19.0
Release 1.19.0: bump submodules, add ldap-test-host fixture
2026-07-30 09:54:15 -04:00
wmantly e25d112787 Release 1.19.0: bump submodules, add ldap-test-host fixture
sso-manager-node -> v1.9.0, proxy -> v1.8.0, jump-host -> v1.10.2,
ldap-client -> v1.0.0 (new)
2026-07-30 09:53:39 -04:00
wmantly 3b5f3423c8 Merge pull request #112 from theta42/feat/jump-host-test-fixture
Add ldap-test-host: a real LDAP-joined downstream host for testing jump-host
2026-07-30 09:48:39 -04:00
wmantly 084c8fdfb3 Add ldap-test-host: a real LDAP-joined downstream host for testing jump-host
New optional service (jump-host compose profile) built from the new
theta42/ldap-client submodule: a genuinely SSSD + AuthorizedKeysCommand
LDAP-joined host, not a container with a manually-dropped public key in
authorized_keys. Lets jump-host's actual key-injection -> upstream-connect
flow be exercised end-to-end against the local dev stack's own LDAP.

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

config.example/ldap-test-host.vars.example documents the one field an
operator fills in (the ldapclient service account's bind password, already
generated by bootstrap.js as serviceAccountPass) -- the rest points at this
stack's own local LDAP by default.
2026-07-30 09:47:54 -04:00
wmantly f2e924bf58 Merge pull request #111 from theta42/release/1.18.0
Release 1.18.0: bump submodules for API-token UI unification
2026-07-28 21:28:33 -04:00
wmantly 7c0fbd959f Release 1.18.0: bump submodules for API-token UI unification
sso-manager-node -> v1.8.3, proxy -> v1.7.0, jump-host -> v1.10.1
2026-07-28 21:27:59 -04:00
wmantly e1cb855529 Bump sso-manager-node to v1.8.1, proxy to v1.6.0 (#110)
Host modal standardization (tabs onto app.modal, footer, linkable URL) plus
two DOM-rebuild-timing autocomplete-datalist bugs fixed in both apps.
2026-07-28 19:10:33 -04:00
wmantly 51e9e5e42f Bump jump-host to v1.9.0 (Quick Jump UI, audit failDetail) (#109) 2026-07-28 18:17:56 -04:00
wmantly e27439e491 Persist Directory data (Resource/site/host/service/oauth) + bump sso-manager-node to v1.8.0 (#108)
sso-manager's Directory data had no persistent volume at all: @simpleworkjs/orm
falls back to './config/inventory.sqlite' (relative to /app) when conf.orm isn't
set, which lives in the container's ephemeral layer -- every container recreate
silently wiped every site/host/service/oauth-client resource and their
relationships. setup.sh's generated sso-secrets.js now sets
orm.storage=/data/inventory.sqlite, co-located with the already-persisted
sso-data volume (same one Redis uses). Verified live: migrated the running
stack's existing Directory data to /data, force-recreated the container, and
confirmed all 8 resources survived.

Also bumps sso-manager-node to v1.8.0 (resource modal standardization: tabs,
footer with created/updated-by metadata, linkable /directory/:slug URLs, a new
Children tab, and site-slug-prefixed auto-created LDAP groups).
2026-07-28 17:51:08 -04:00
wmantly cf8c5c9a04 Bump jump-host to v1.8.1 (#107) 2026-07-28 15:58:42 -04:00
wmantly c7c0aa8cf5 Persist jump-host's Redis data across rebuilds (#106)
Companion to theta42/jump-host#18: that PR turns on Redis persistence
(AOF + RDB) at /data, but without a volume mount, persistence-within-the-
container is pointless -- docker rm -f (which every real rebuild uses,
per this repo's own documented docker-compose-v1 recreate quirk) throws
the container's whole filesystem away regardless. Adds jump-redis-data:/data,
matching proxy-data's existing role for the same package (lua-resty-auto-ssl
also relies on Redis persistence, via its own volume).

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:15:47 -04:00
82 changed files with 8145 additions and 509 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# theta-env — unified SSO Manager + Proxy deployment.
# theta-suite — unified SSO Manager + Proxy deployment.
#
# Copy this file to `.env` and fill in the values, then run `./setup.sh`.
# All values are read by setup.sh / docker-compose / the bootstrap.
+62
View File
@@ -0,0 +1,62 @@
name: CI/CD
on:
push:
branches: [ "main", "master" ]
tags:
- 'v*.*.*'
pull_request:
branches: [ "main", "master" ]
jobs:
build-theta-agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build Agent
working-directory: ./theta-agent
run: go build -v ./...
docker-push:
needs: [build-theta-agent]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push SSO Manager
uses: docker/build-push-action@v4
with:
context: ./sso-manager-node
file: ./sso-manager-node/Dockerfile.openldap
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/sso-manager:latest
ghcr.io/${{ github.repository_owner }}/sso-manager:${{ github.ref_name }}
- name: Build and Push Proxy
uses: docker/build-push-action@v4
with:
context: ./proxy
file: ./proxy/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/theta-proxy:latest
ghcr.io/${{ github.repository_owner }}/theta-proxy:${{ github.ref_name }}
+7 -2
View File
@@ -1,8 +1,10 @@
name: Lint
# theta-env has no app code of its own to unit-test (it orchestrates the
# theta-suite has no app code of its own to unit-test (it orchestrates the
# proxy/sso-manager-node submodules) -- this checks the one thing that can
# actually break silently: setup.sh and bootstrap.js.
# actually break silently: setup.sh and bootstrap.js, plus a static
# consistency check on the config bootstrap.js generates for jump-host
# (test/check_jump_ldap_tls.js).
on:
pull_request:
branches:
@@ -39,3 +41,6 @@ jobs:
- name: Syntax check
run: node --check bootstrap/bootstrap.js
- name: Jump-host LDAP config consistency
run: node test/check_jump_ldap_tls.js
+6 -2
View File
@@ -5,8 +5,12 @@
config/
backups/
# Legacy .env / proxy.env (no longer used — config is in ./config/). Still
# ignored in case a migrated deployment hasn't deleted them yet.
# .env: NOT app config (that's ./config/, generated by setup.sh) — this is
# docker compose's own auto-loaded env file, which setup.sh uses only to
# persist *_GIT_COMMIT build args so an ad-hoc rebuild of a single service
# still bakes the right commit hash. Generated; never commit.
# proxy.env: legacy, no longer used — still ignored in case an old
# deployment hasn't deleted it yet.
.env
proxy.env
+6
View File
@@ -8,3 +8,9 @@
path = jump-host
url = https://github.com/theta42/jump-host.git
branch = master
[submodule "ldap-client"]
path = ldap-client
url = https://github.com/theta42/ldap-client.git
[submodule "theta-agent"]
path = theta-agent
url = https://github.com/theta42/theta-agent.git
+1028 -35
View File
File diff suppressed because it is too large Load Diff
+67 -62
View File
@@ -1,9 +1,10 @@
# theta-env
# theta-suite
The whole theta42 identity + access stack in one repo, brought up with a single
command — for home labs and small businesses.
It wires together two projects that already work on their own:
It composes four applications around a shared [OpenBao](https://openbao.org/)
secrets store, brought up with one command:
- **[SSO Manager](https://github.com/theta42/sso-manager-node)** — an OIDC
provider with a built-in LDAP directory (OpenLDAP) and a web UI for managing
@@ -11,12 +12,15 @@ It wires together two projects that already work on their own:
- **[theta42/proxy](https://github.com/theta42/proxy)** — an OIDC-protected
reverse proxy (OpenResty) that puts any of your apps behind SSO login and can
look users up directly in LDAP.
- **[Jump Host](https://github.com/theta42/jump-host)** — directory-driven SSH
access to your machines through one public entry point.
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls Linux
hosts into the directory for PAM/SSSD login, sudo, and SSH keys.
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.
All four load their secrets from OpenBao at boot; `setup.sh` automates the
first-run glue so they find each other and the secrets store.
**Documentation:** [https://theta42.github.io/theta-env/](https://theta42.github.io/theta-env/)
**Documentation:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
## Screenshots
@@ -26,14 +30,18 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
| --- | --- |
| [![SSO Manager dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
**Why use this instead of running the two separately?** The two only become
useful once the proxy is registered as an OIDC client of the SSO and pointed at
the SSO's LDAP directory — and the SSO's domain has to match across half a dozen
config fields or logins silently fail with `Invalid Credentials`. Doing that by
hand is fiddly and easy to get wrong. `setup.sh` asks for your domain once (in
`setup.env`), generates both config files with it filled in everywhere, registers
the proxy as an OIDC client, and snapshots state before every rebuild — so you
get a working SSO + proxy stack in one command and a safe way to upgrade it.
## Configuration
`setup.sh` automates the first-run glue between subprojects:
- Asks for your domain once (in `setup.env`) and fills it in across all config files.
- Registers the proxy as an OIDC client of the SSO.
- Persists submodule commit hashes in `.env` for reproducibility (e.g., `SSO_GIT_COMMIT`, `PROXY_GIT_COMMIT`). This ensures future `docker compose` runs use the same submodule versions.
**Why use this instead of running the two separately?** The two only become useful once the proxy is registered as an OIDC client of the SSO and pointed at the SSO's LDAP directory — and the SSO's domain has to match across half a dozen config fields or logins silently fail with `Invalid Credentials`. Doing that by hand is fiddly and easy to get wrong. `setup.sh` handles this automatically and snapshots state before every rebuild — so you get a working SSO + proxy stack in one command and a safe way to upgrade it.
## Unified Release Status
-**Phase 1 (oidc-client)**: Complete.
-**Phases 2-5**: Pending (see [roadmap](#)).
```
┌──────────────────────────────────────────────┐
@@ -121,26 +129,29 @@ see browser warnings.)
Optional extra ports (only if you need them):
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
- **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
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
enrollment (`setup.sh` → ldap-client) configures its sssd against
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
bind over either; the proxy itself reaches LDAP over the internal Docker
network and doesn't need them.
**Do not forward 389/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
Any recent Docker with Compose — the v2 plugin (`docker compose`) or the v1
standalone (`docker-compose`) both work.
You must use the modern Docker Compose v2 plugin (`docker compose`). The older v1 standalone (`docker-compose`) is not compatible with the BuildKit images generated by this suite and will fail with a `ContainerConfig` KeyError during deployment.
---
## Quickstart
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
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
```
@@ -177,11 +188,21 @@ operator-owned and `setup.env` is ignored.
would 404. Idempotent; skips a host that already exists.
6. Prints your first admin login + the public URLs.
### Configuration — `./config/` (no `.env` files)
### Configuration & secrets — OpenBao + `./config/`
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which
the entrypoint points at the mounted file:
Secrets live in **OpenBao** (a Vault fork, container `openbao:8200` on
`theta-net`), the single authoritative store. Each app loads them at boot with
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/<app>/conf` over the file-loaded config — **fail-soft**, so
if OpenBao is unreachable the app boots from the file fallback. End users get
personal per-user secret storage (`secret/users/<uid>/*`) in the SSO **Vault**
UI, and admins mint scoped tokens for external apps (`secret/apps/<name>/*`).
See **[docs/secrets.md](docs/secrets.md)** for the full architecture, policies,
token model, rotation, and the external-app convention.
A bind-mounted `./config/` directory (gitignored) holds the operator-edit
seed files and the fail-soft fallback, read by each app's `@simpleworkjs/conf`
via the `CONF_SECRETS` env var:
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
@@ -192,11 +213,15 @@ the entrypoint points at the mounted file:
same `serviceAccountPass`), `auth` (admin groups/users).
`./setup.sh` generates both on first run from `./setup.env` (the one place the
domain is entered — see *Quickstart*) with random secrets. There is **no
`.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates
port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the
command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the
full annotated shape, and each submodule's `secrets.js.example`.
domain is entered — see *Quickstart*) with random secrets, seeds them into
OpenBao, and mints scoped per-app tokens (`SSO_VAULT_TOKEN` /
`PROXY_VAULT_TOKEN` / `JUMP_VAULT_TOKEN`) into `./.env`. There is **no
`.env` / `proxy.env`** for app config — edit `./config/*.js` directly (and
re-seed into OpenBao, or use the SSO Configuration UI for live edits). Compose
only interpolates port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can
override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
`config.example/` for the full annotated shape, and each submodule's
`secrets.js.example`.
> **Migrating from an older `.env`-based deployment?** If `.env` and/or
> `proxy.env` exist when you first run `./setup.sh`, it migrates them into
@@ -215,9 +240,10 @@ full annotated shape, and each submodule's `secrets.js.example`.
- **Proxy mgmt UI**: `https://<PROXY_HOST>` — add the Host records you want to
protect with OIDC. (First-run fallback: `http://<host>:3000`, reachable on the
LAN by default.)
- **Direct LDAP for legacy apps**: bind to `ldaps://<host>:636` as
`cn=admin,<base>` (admin) or `cn=ldapclient,ou=people,<base>` (read-only
service account the bootstrap created). Use LDAPS, not plain LDAP.
- **Direct LDAP for LDAP-native clients and Linux hosts**: bind to
`ldaps://<host>:636` as `cn=admin,<base>` (admin) or
`cn=ldapclient,ou=people,<base>` (read-only service account the bootstrap
created). Use LDAPS, not plain LDAP.
### API tokens (personal access tokens)
@@ -325,8 +351,11 @@ docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb
docker compose exec proxy redis-cli BGSAVE
docker compose cp proxy:/data/dump.rdb proxy.rdb
# Secrets
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative)
cp -a ./config config-backup && chmod 700 config-backup
# OpenBao (the authoritative secret store — back up its data volume)
docker run --rm -v theta-suite_openbao-data:/data -v "$PWD":/backup alpine \
tar czf /backup/openbao-data.tgz -C /data .
```
### Restore — full disaster recovery
@@ -377,30 +406,6 @@ Redis and are preserved by the volume.
---
## Running each project standalone
The two submodules work on their own — this repo just composes them:
- **SSO Manager alone**:
```bash
cd sso-manager-node
mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build
```
See its [DEPLOYMENT.md](sso-manager-node/DEPLOYMENT.md).
- **Proxy alone** (pointing at any external SSO + LDAP via a mounted
`secrets.js`):
```bash
cd proxy
mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build
```
See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md).
No cross-repo file edits are needed at runtime — the unified stack is pure
composition (one compose file + one bootstrap script).
---
## How the first-run wiring works
@@ -465,7 +470,7 @@ exactly in the bootstrap) so the SSO can verify them on bind.
## Repo layout
```
theta-env/
theta-suite/
├── 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
@@ -486,7 +491,7 @@ 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
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-suite 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)
+295 -56
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/*
* theta-env bootstrap — runs inside the sso-manager container to wire the
* theta-suite bootstrap — runs inside the sso-manager container to wire the
* proxy into a (fresh or existing) SSO Manager. Invoked by setup.sh:
*
* docker compose exec sso-manager node /bootstrap/bootstrap.js
@@ -23,6 +23,13 @@
* into the file (the sso-manager mounts ./config
* read-write for this purpose).
*
* Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when
* the jump host is enabled, secret/jump-host/conf) so the proxy + jump host
* load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes
* the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is
* fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config
* file remains the fallback and bootstrap does not fail the bring-up over it.
*
* Idempotent: re-running converges to the ./config values. The LDAP service
* account + admin passwords are reset to the file values on each run; the
* OAuth client is created if missing. If proxy-secrets.js already holds a
@@ -82,11 +89,55 @@ const CLIENT_NAME = 'theta-proxy';
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin'];
// god_admin is the global super group (docs/GROUPS.md §2); the bootstrapped
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the
// per-console admin groups still used by the SSO UI. god_admin is nested into
// the app_sso_* groups (and every resource's _admin group) by
// docker-entrypoint.sh + api_directory_admin, so LDAP-level consumers (SSSD,
// sudo) resolve it transitively.
const ADMIN_GROUPS = ['god_admin', 'app_sso_admin', 'app_sso_oauth_admin'];
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
// ── OpenBao (Vault) writes ───────────────────────────────────────────────────
// bootstrap generates the proxy's + jump host's OAuth client creds and writes
// them back into /config/*-secrets.js (the file fallback). It ALSO writes the
// complete conf into OpenBao so the proxy + jump host load it from there at
// boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this
// exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write
// is skipped with a warning — the file remains the fallback and bootstrap does
// not fail the bring-up over it.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const VAULT_TOKEN = process.env.VAULT_TOKEN || '';
// Re-require a /config module after its file has been rewritten on disk
// (require caches the old contents otherwise).
function freshRequire(p) {
delete require.cache[require.resolve(p)];
return require(p);
}
// PUT (replace) the data at secret/data/<vaultPath> with `data`. Warn-only.
async function baoPut(vaultPath, data) {
if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; }
try {
const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, {
method: 'POST',
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`);
} else {
log(`OpenBao: wrote secret/${vaultPath}`);
}
} catch (e) {
log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`);
}
}
// Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the
// directory stores passwords the SSO can verify on bind (pw-sha2 module).
function hashPasswordSSHA512(password) {
@@ -127,31 +178,88 @@ function ldapModify(ldif) {
}
// ── 1. LDAP service account for the proxy ───────────────────────────────────
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
// UI as a service account it must (a) match the user filter (posixAccount) and
// (b) be a member of app_sso_service_account (that membership is what the Users
// page marks as a non-person/service account). Older bootstraps created it as a
// bare organizationalRole (invisible to the Users list) and never joined the
// group, so it never appeared. Both are fixed here; the existing-path shape add
// is best-effort so a pre-existing account still binds even if the upgrade add
// fails.
function ensureServiceAccount() {
const pw = hashPasswordSSHA512(SVC_PASS);
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
if (entryExists(SVC_DN)) {
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
const r = ldapModify([
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
// Add the auxiliary posixAccount objectClass + required attrs so the entry
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
// it is structural and would conflict with the existing organizationalRole.
const shape = [
`dn: ${SVC_DN}`,
'changetype: modify',
'add: objectClass',
'objectClass: posixAccount',
'-',
'add: uid',
'uid: ldapclient',
'-',
'add: uidNumber',
`uidNumber: ${uidNum}`,
'-',
'add: gidNumber',
`gidNumber: ${uidNum}`,
'-',
'add: homeDirectory',
'homeDirectory: /nonexistent',
'-',
'add: description',
'description: LDAP bind service account (proxy / ldap-client)',
'',
].join('\n');
const rs = ldapModify(shape);
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
log(' service-account shape warning (account still binds):', rs.stderr.trim());
}
const rp = ldapModify([
`dn: ${SVC_DN}`,
'changetype: modify',
'replace: userPassword',
`userPassword: ${pw}`,
'',
].join('\n'));
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
return;
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
} else {
log(`Creating service account ${SVC_DN}`);
const entry = [
`dn: ${SVC_DN}`,
'objectClass: inetOrgPerson',
'objectClass: posixAccount',
'objectClass: top',
'cn: ldapclient',
'sn: ldapclient',
'uid: ldapclient',
`uidNumber: ${uidNum}`,
`gidNumber: ${uidNum}`,
'homeDirectory: /nonexistent',
'description: LDAP bind service account (proxy / ldap-client)',
`userPassword: ${pw}`,
'',
].join('\n');
const r = ldapAdd(entry);
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
}
log(`Creating service account ${SVC_DN}`);
const r = ldapAdd([
`dn: ${SVC_DN}`,
'objectClass: organizationalRole',
'objectClass: simpleSecurityObject',
'objectClass: top',
'cn: ldapclient',
`userPassword: ${pw}`,
// Mark it as a service account (the Users UI's service-account signal).
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
const rm = ldapModify([
`dn: ${gdn}`,
'changetype: modify',
'add: member',
`member: ${SVC_DN}`,
'',
].join('\n'));
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
}
// ── 2. First admin user ─────────────────────────────────────────────────────
@@ -230,7 +338,7 @@ async function listClients(token) {
}
async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
@@ -334,7 +442,7 @@ const HOST_FACTS = {
kernel: process.env.STACK_HOST_KERNEL || '',
};
async function seedDirectory(token, clientId) {
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Create a resource unless its slug (or a legacy alternate from an earlier
@@ -378,15 +486,46 @@ async function seedDirectory(token, clientId) {
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
subType: 'linux',
ip: HOST_FACTS.ip,
address: HOST_FACTS.ip,
macAddress: HOST_FACTS.mac,
os: HOST_FACTS.os,
kernel: HOST_FACTS.kernel,
sshPort: 22,
managed: true,
}, ['stack-host']);
// theta-proxy and theta-jump are first-class managed host resources (their
// names match the OAuth client identities the proxy/jump apps use). They
// appear as hosts in the Directory; the per-app services below still carry
// the OAuth-client + reachability detail.
const jumpHostAddr = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, {
subType: 'linux',
address: `https://${PROXY_HOST}`,
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
icon: 'mdi:server-network',
tagline: 'Reverse proxy and API gateway (node management UI).',
managed: true,
});
await ensure('host', 'theta-jump', 'host_theta-jump', site.id, {
subType: 'ssh',
address: jumpHostAddr ? `https://${jumpHostAddr}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
icon: 'mdi:ssh',
tagline: 'Secure SSH jump host.',
managed: true,
});
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',
icon: 'mdi:shield-account',
tagline: 'Home-lab identity and access management.',
requestable: false,
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
@@ -395,6 +534,9 @@ async function seedDirectory(token, clientId) {
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'web',
icon: 'mdi:server-network',
tagline: 'Reverse proxy and API gateway.',
requestable: false,
});
// OpenLDAP is independently consumed — Linux hosts authenticate against it
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
@@ -406,8 +548,12 @@ async function seedDirectory(token, clientId) {
address: `ldaps://${LDAPS_HOST}:636`,
port: 389,
externalPort: 636,
portMappings: [{ proto: 'tcp', external: 636, internal: 389, comment: 'LDAPS' }],
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'openldap',
icon: 'mdi:book-open-outline',
tagline: 'LDAP directory for identity.',
requestable: false,
});
// Wildcard address: OpenResty fronts every host under the domain (same
// */** wildcard convention the proxy's Host records use). Its config lives
@@ -417,32 +563,94 @@ async function seedDirectory(token, clientId) {
port: 443,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'openresty',
icon: 'mdi:router-network',
tagline: 'Data plane.',
requestable: false,
});
// Optional SSH jump host service.
if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) {
// SSH jump host service (core component — always registered).
let jumpSvc = null;
{
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
subType: 'ssh',
icon: 'mdi:ssh',
tagline: 'Secure SSH jump host.',
requestable: false,
});
}
// Link the proxy's OAuth client (Resource-backed since sso-manager 1.3.0)
// under its service, if it appears in the directory and isn't linked yet.
if (clientId) {
const oauthRes = resources.find((r) => r.id === clientId);
if (oauthRes) {
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === clientId);
if (!linked) {
await dirPost(token, 'edges', { parentId: psvc.id, childId: clientId, relation: 'oauth' });
log(` directory: linked OAuth client under 'proxy'`);
}
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
await linkOauthClient(clientId, psvc, 'proxy');
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// ── Plugin instances ────────────────────────────────────────────────────────
// Seed a sensible default set of plugin instances so the stack is usable the
// moment it boots, without the operator having to add them by hand. The setup
// stack runs on Docker, so the single biggest win is a Docker discovery plugin
// pointed at the local daemon socket: containers that make up the stack (and
// any others on the host) get discovered into the Directory automatically.
// Idempotent per slug: an instance an operator already created is left alone.
async function seedPlugins(token) {
async function pluginGet(path) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/plugins/${path} failed (${res.status})`);
return res.json();
}
async function pluginPost(body) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/`, {
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/plugins failed (${res.status}): ${text}`);
}
return res.json();
}
async function ensurePlugin({ pluginType, name, slug, config }) {
const existing = ((await pluginGet('')).results) || [];
if (existing.some((i) => i.slug === slug)) {
log(` plugins: '${slug}' exists — keeping`);
return;
}
await pluginPost({ pluginType, name, slug, config });
log(` plugins: created '${slug}' (${pluginType})`);
}
try {
// The Docker daemon the setup stack itself runs under. The socket must be
// mounted into the sso container for discovery to reach it; if it isn't,
// discovery simply errors non-fatally until it is.
await ensurePlugin({
pluginType: 'docker',
name: 'Local Docker daemon',
slug: 'docker-local',
config: { socketPath: '/var/run/docker.sock' },
});
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
@@ -475,16 +683,16 @@ function writeProxyCreds(id, secret) {
}
}
// ── 6. Optional: provision the SSH jump host ────────────────────────────────
// When CFG_JUMP_HOST_ENABLED=true, the jump host needs: a directory API token
// (to resolve which hosts a user may reach), an LDAP bind account that can
// WRITE the sshPublicKey attribute (it injects its own key on first use), and
// a config file it reads. We write /config/jump-secrets.js deriving LDAP/site
// from sso-secrets.js + a freshly minted API token. The bundled jump host
// binds as cn=admin (already able to write sshPublicKey) — hardened bare-metal
// deployments should use a scoped account + attribute ACL instead (see the
// jump-host README). Idempotent: skips if the file already has a real token.
const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '');
// ── 6. Provision the SSH jump host ─────────────────────────────────────────
// The jump host is a core component (always provisioned). It needs: a directory
// API token (to resolve which hosts a user may reach), an LDAP bind account
// that can WRITE the sshPublicKey attribute (it injects its own key on first
// use), and a config file it reads. We write /config/jump-secrets.js deriving
// LDAP/site from sso-secrets.js + a freshly minted API token. The bundled jump
// host binds as cn=admin (already able to write sshPublicKey) — hardened
// bare-metal deployments should use a scoped account + attribute ACL instead
// (see the jump-host README). Idempotent: skips if the file already has a real
// token.
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
const JUMP_SECRETS = '/config/jump-secrets.js';
const JUMP_TOKEN_NAME = 'theta-jump-host';
@@ -495,7 +703,7 @@ 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)' }),
body: JSON.stringify({ name, description: 'theta-suite 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();
@@ -520,12 +728,11 @@ function writeJumpSecrets(apiToken, oidc, localAdminPass) {
const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict';
// Generated by theta-env bootstrap. The jump host reads this via
// Generated by theta-suite 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\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
\t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
@@ -582,10 +789,17 @@ module.exports = {
fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 });
}
// Returns the jump host's OAuth client id (so seedDirectory can link it under
// the SSH Jump Host service), whether or not this run actually wrote a fresh
// jump-secrets.js -- otherwise re-runs on an already-configured deployment
// never get a chance to self-heal a missing directory link (see the "no
// parent" bug this was written for).
async function provisionJumpHost(token) {
if (jumpFileComplete()) {
log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
return;
const clients = await listClients(token);
const existing = clients.find((c) => c.name === JUMP_CLIENT_NAME);
return existing ? existing.client_id : null;
}
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
@@ -598,7 +812,7 @@ async function provisionJumpHost(token) {
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-env jump host web UI (auto-registered)',
description: 'theta-suite jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
@@ -607,6 +821,7 @@ async function provisionJumpHost(token) {
writeJumpSecrets(apiToken, oidc, localAdminPass);
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
return oidc.id;
}
(async function main() {
@@ -652,26 +867,50 @@ async function provisionJumpHost(token) {
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`);
}
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
// fresh: writeProxyCreds rewrote the file out from under the cached
// `proxy` object. setup.sh's seed already put a placeholder version
// here; this replaces it with the complete file (operator edits +
// generated OAuth creds). Warn-only.
await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js'));
// Provision the jump host (mint token + write config). Warn-only — never
// fail the whole bring-up over it, but it's a core component so always
// attempted (no longer gated by CFG_JUMP_HOST_ENABLED).
let jumpClientId = null;
try {
jumpClientId = await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
// Mirror jump-secrets.js (just written by provisionJumpHost) into
// OpenBao so the jump host loads it from there at boot via
// @simpleworkjs/bao-conf. setup.sh's seed may have put a
// placeholder/stale version here; this replaces it with the
// complete file (LDAP bind, minted API token, OAuth client).
// Warn-only.
await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS));
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
// Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue.
try {
log('Seeding directory resources...');
await seedDirectory(token, resolvedClientId);
await seedDirectory(token, resolvedClientId, jumpClientId);
} catch (e) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
// Seed default plugin instances (Docker discovery) — same warn-and-go
// policy; a stack without plugins is still usable.
try {
log('Seeding default plugins...');
await seedPlugins(token);
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
log('Done.');
process.exit(0);
} catch (e) {
@@ -0,0 +1,26 @@
# ldap-client config for the optional local jump-host test fixture
# (ldap-test-host service in docker-compose.yml, jump-host compose profile).
# Copy to ./config/ldap-test-host.vars and fill in the bind password from
# your own ./config/sso-secrets.js's `serviceAccountPass` (the
# cn=ldapclient,ou=people,<base> service account bootstrap/bootstrap.js
# creates specifically for this kind of 3rd-party/container LDAP bind).
#
# This is what lets ldap-test-host be a REAL SSSD+AuthorizedKeysCommand-joined
# downstream host, so jump-host's key-injection -> upstream-connect flow can
# be exercised end-to-end against something more than a container with a
# manually-dropped public key in authorized_keys.
export ldap_host="sso-manager"
export ldap_base_dn="dc=localtest,dc=me"
export ldap_bind_dn="cn=ldapclient,ou=People,$ldap_base_dn"
export ldap_bind_password="REPLACE_WITH_serviceAccountPass_FROM_sso-secrets.js"
# sso_url/sso_token deliberately left unset -- register the host + access
# group manually via the Directory admin API instead (index.sh's optional
# auto-registration also wants a parent site Resource to exist first).
# index.sh gates that block on `[[ -v sso_token ]]`, which is true even for
# an empty string, so leave these genuinely absent, not "".
export ldap_location="jumptest"
ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )
+1 -1
View File
@@ -1,5 +1,5 @@
'use strict';
// Example proxy secrets for the theta-env unified stack. Copy to
// Example proxy secrets for the theta-suite unified stack. Copy to
// ./config/proxy-secrets.js (NOT this file — ./config/ is gitignored) and edit.
// `./setup.sh` generates ./config/proxy-secrets.js for you on first run and the
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
+11 -1
View File
@@ -1,5 +1,5 @@
'use strict';
// Example SSO secrets for the theta-env unified stack. Copy to
// Example SSO secrets for the theta-suite unified stack. Copy to
// ./config/sso-secrets.js (NOT this file — ./config/ is gitignored) and edit.
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
// documents the shape for manual editing / reference.
@@ -30,6 +30,16 @@ module.exports = {
jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
},
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
// (relative to the app's /app cwd) -- inside the container's ephemeral
// layer, not any mounted volume, so every Resource/site/host/service/oauth
// row (the whole Directory Management page) would be silently wiped on
// every container recreate. /data is already a persisted volume (Redis
// lives there too), so this just co-locates the sqlite file with it.
orm: {
dialect: 'sqlite',
storage: '/data/inventory.sqlite',
},
// ── Orchestrator-only (ignored by the app; read by setup.sh + bootstrap) ──
stack: {
+127 -19
View File
@@ -1,4 +1,4 @@
# theta-env — unified SSO Manager + Proxy.
# theta-suite — unified SSO Manager + Proxy.
#
# Brings up the two all-in-one images on one bridge network so the proxy can
# reach the SSO internally (http://sso-manager:3001 for token/userinfo,
@@ -44,26 +44,37 @@ services:
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: sso-manager
restart: unless-stopped
depends_on:
- openbao
networks: [theta-net]
ports:
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
- "${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.
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
# against ldap://localhost and ldaps://localhost, and the LDAP server is
# co-located on this host, so BOTH ports must be reachable from the host
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
environment:
# Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see
# volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app
# reads that are not part of its conf tree.
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
# deep-merges secret/sso-manager/conf from OpenBao over it at boot
# (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other
# env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by
# setup.sh (policy sso-broker) — NOT the root token.
- NODE_ENV=production
- NODE_PORT=3001
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
# runtime. See the build args above for the same setting during build.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
@@ -113,6 +124,8 @@ services:
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
@@ -122,10 +135,17 @@ services:
# to lock it to localhost once the proxy fronts it under TLS.
- "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000"
environment:
# oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes),
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
# oidc/ldap/auth config is loaded by @simpleworkjs/conf from
# ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf
# deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth
# clientSecret is consumed at require time, so bao-conf.init() runs
# BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env
# the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN
# (policy proxy — read only secret/proxy/conf).
- NODE_ENV=production
- NODE_PORT=3000
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's
# Encrypt, DNS providers) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
@@ -134,7 +154,9 @@ 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 points CONF_SECRETS at /config/proxy-secrets.js.
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a
# fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is
# unreachable the app boots from this file instead.
- ./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.
@@ -152,12 +174,10 @@ services:
retries: 3
start_period: 30s
# Optional SSH jump host. Only started when the `jump-host` compose profile
# is active — setup.sh exports COMPOSE_PROFILES=jump-host when
# CFG_JUMP_HOST_ENABLED=true. Authenticates users against the SSO's OpenLDAP,
# resolves reachable hosts from the directory API, and bridges SSH through.
# SSH jump host — a core component, always built + started alongside the
# SSO and proxy. Authenticates users against the SSO's OpenLDAP, resolves
# reachable hosts from the directory API, and bridges SSH through.
jump-host:
profiles: ["jump-host"]
build:
context: ./jump-host
dockerfile: Dockerfile
@@ -174,11 +194,19 @@ services:
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
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
# Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js,
# then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from
# OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN
# (policy jump-host — read only secret/jump-host/conf).
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (the directory API
# client) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
@@ -187,7 +215,85 @@ services:
volumes:
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
- jump-data:/var/lib/jump-host # generated host keys persist here
- jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here
# A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for
# testing jump-host's actual key-injection -> upstream-connect flow --
# a container with a manually-dropped public key in authorized_keys never
# exercises the LDAP-key-serving path a real production host does. Built
# from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars
# for setup notes. Opt-in test fixture: bring it up explicitly with
# `docker compose --profile ldap-test up` (jump-host itself now starts
# unconditionally, so this only adds a downstream host for it to reach).
ldap-test-host:
profiles: ["ldap-test"]
build:
context: ./ldap-client
dockerfile: Dockerfile
container_name: ldap-test-host
hostname: ldap-test-host
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
privileged: false
volumes:
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
# Renews the three periodic service tokens (theta-svc role, 768h period)
# every 12h. Periodic tokens live forever ONLY while something renews them —
# this sidecar is that something, so the stack survives arbitrarily long
# uptimes and the tokens in .env never silently expire. If a token is missing
# or already dead it just logs and moves on (setup.sh re-mints on next run).
bao-renewer:
image: quay.io/openbao/openbao:latest
container_name: bao-renewer
restart: unless-stopped
depends_on:
- openbao
environment:
- BAO_ADDR=http://openbao:8200
- SSO_VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
- PROXY_VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
- JUMP_VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
entrypoint: ["/bin/sh", "-c"]
command:
- |
renew() {
if [ -z "$$2" ]; then return 0; fi
if BAO_TOKEN="$$2" bao token renew > /dev/null 2>&1; then
echo "[bao-renewer] renewed $$1"
else
echo "[bao-renewer] FAILED to renew $$1 (expired/revoked? re-run setup.sh to re-mint)"
fi
}
while true; do
renew SSO_VAULT_TOKEN "$$SSO_VAULT_TOKEN"
renew PROXY_VAULT_TOKEN "$$PROXY_VAULT_TOKEN"
renew JUMP_VAULT_TOKEN "$$JUMP_VAULT_TOKEN"
sleep 43200
done
networks:
- theta-net
openbao:
image: quay.io/openbao/openbao:latest
container_name: openbao
restart: unless-stopped
cap_add:
- IPC_LOCK
command: server -config=/vault/config/openbao.hcl
environment:
- BAO_ADDR=http://127.0.0.1:8200
ports:
- "8080:8200"
volumes:
- ./config/openbao.hcl:/vault/config/openbao.hcl:ro
- openbao-data:/vault/data
networks:
- theta-net
networks:
theta-net:
driver: bridge
@@ -199,4 +305,6 @@ volumes:
proxy-data:
proxy-cache:
proxy-logs:
jump-data:
jump-data:
jump-redis-data:
openbao-data:
+323
View File
@@ -0,0 +1,323 @@
---
layout: default
title: Group & Permission Model
nav_order: 3
---
# Theta42 Group & Permission Model
This is the canonical reference for how **groups and permissions work** across the
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
hosts** should read and use them. It is written to be implementable by both humans
and LLM agents.
Everything below assumes LDAP is the single source of truth for identity and group
membership. Group membership is managed in the **SSO Manager Directory**, generated
from adopted resources — there is **no standalone "Groups" page**.
---
## 1. Principles
1. **Groups are a projection of the resource graph.** Every adopted host and app
in the Directory gets its own groups, auto-created from its identity. Group
membership is managed on the resource's modal.
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
hosts; an app administers apps. They do not inherit from each other.
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
`admin` implies `access`. Capabilities are explicit and never implied by
`admin`.
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
the site slug.
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
bloat. Only the meta groups are never imported by hosts.
6. **The directory is the only place groups are created.** `god_admin` is the sole
group that does not belong to a resource or site.
---
## 2. Group schema
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
`<capability>` = an opaque, app-defined capability token (see §4).
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
| `S_host_<host>_access` | host | Access to host `<host>`. |
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
| `S_apps_access` | site | Access to **all apps** at `S`. |
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
| `S_app_<app>_access` | app | Access to app `<app>`. |
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
### Meta groups (implicit membership — not POSIX, no gidNumber)
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `everyone` | global | **All authenticated users**, any site. |
| `S_everyone` | site | **All authenticated users** at site `S`. |
These are resolved by the directory (any authenticated user passes), never
enumerated as LDAP members, and cannot be used as Unix groups.
---
## 3. Naming, normalization & reserved rules
- The **structural delimiter is `_`**. It appears only between the fixed segments
of a group name.
- **The `S` site segment is the site resource's slug verbatim** (`site_local`),
NOT re-slugified (which would corrupt the delimiter: `site_local``site-local`).
- **Per-resource groups are `{S}_{kind}_{name}_{level}`.** `kind` is `host` or
`app`; `name` is the resource's **name slug with the kind prefix stripped** — a
host resource `host_theta-env` has name `theta-env`, so its groups are
`site_local_host_theta-env_access` / `_admin`. A service (the group model's
`app`, docs §11) `sso-manager` gives `site_local_app_sso-manager_access`. The
kind segment is always present, which is what makes a resource's name
unambiguous even if a host and a service share a name.
- **Within a segment, normalize to lowercase** — spaces and stray `_``-`; strip
other non-`[a-z0-9-]`. A host named `Web 01` and a site `Main Office` (resource
slugs `host_web-01` and `site_main-office`) yield groups `site_main-office_host_web-01_*`.
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
if a host were named `admin` (that host would be `S_host_admin_admin`).
- **The last segment is the level.** If it is `admin` or `access` it is a known
level; any other value is an **opaque capability** owned by a downstream app.
- **Total length budget:** keep a group cn under ~120 chars; reject group
creation that would exceed it.
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
generated on the host by SSSD for only the groups that host imports (see §8).
---
## 4. Levels and opaque capabilities
- **`admin`** — manage (create/update/delete/config) the resource.
- **`access`** — use/read the resource.
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
manages membership and exposes the group to the app; **the downstream app
defines and enforces what the capability means** (e.g. `emby_admin`,
`gitea_maintain`, `reboot`, `backup`).
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
Everything else on a resource group is treated as an opaque capability group and
passed through to consumers.
---
## 5. Permission resolution (inheritance)
Define a user's **effective permission** on a resource by checking, from most
specific to most general, whether they are a member of any applicable group. The
rule: a higher group implies everything below it.
### On host `H` at site `S`
| Wanted | Granted if the user is a member of **any** of |
| :--- | :--- |
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
### On app `A` at site `S`
Identical, with `app`/`apps` substituted for `host`/`hosts`.
### Management console (SSO / Proxy / Jump-Host)
Each console is registered as an **app** on its site, so console admin is:
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
### Pseudocode
```
def effective(resource, level_or_cap, site):
if user in "god_admin": return True
if user in f"{site}_super_admin": return True
if level_or_cap in ("admin","access"):
agg = f"{site}_{resource.kind}s_{level_or_cap}"
if user in agg: return True
# resource.name is the resource's name slug (kind prefix stripped); the kind
# is its own segment. A host `host_theta-env` has name `theta-env`, kind `host`.
specific = f"{site}_{resource.kind}_{resource.name}_{level_or_cap}"
if user in specific: return True
if level_or_cap == "access": return effective(resource, "admin", site)
if level_or_cap == "admin": return False # access does not imply admin
return False
```
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
---
## 6. Where groups live — the Directory, generated from adopted resources
- There is **no standalone Groups page.** Group creation/management happens on an
**adopted resource** in the Directory.
- When a host or app is **adopted** (promoted from Discovered Inventory to
managed), the directory auto-creates its `_admin` and `_access` groups (and
site aggregates if configured). Capability groups are created on demand.
- Membership (add/remove users) and capability grants are managed on that
resource's modal.
- Deleting a resource removes its per-resource groups.
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
global `god_admin`/`everyone` are managed at the site level (not on a single
host/app resource).
---
## 7. Multi-site isolation
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
`everyone` cross site boundaries.
---
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
There are hundreds of them and only a handful matter on any given host, so we do
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
This keeps the directory clean and the per-host surface tiny.
### SSSD — generate GIDs on the fly, import only what you need
```ini
[domain/example]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example
ldap_search_base = dc=example,dc=com
# groupOfNames (RFC 2307bis) schema
ldap_schema = rfc2307bis
ldap_group_object_class = groupOfNames
ldap_group_member = member
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
ldap_id_mapping = true
ldap_group_uuid = entryUUID
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
```
Key ideas:
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
stable GID for any group it imports, so **no `gidNumber` attribute is required**
in LDAP.
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
match, discarding the other hundreds. After changing the filter, clear the
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
`getent group <cn>`.
### What filter to use — the naming convention is the answer
A host should import its **own** resource groups (plus any explicitly granted
ones). Because the schema is predictable, `ldap-client` can generate the per-host
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
at site `main-office` (site resource slug `site_main-office`) imports:
```
(&(objectClass=groupOfNames)(|(cn=site_main-office_host_web01_access)
(cn=site_main-office_host_web01_admin)
(cn=site_main-office_host_web01_sudo)))
```
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
### Aliasing an LDAP group into a local group (e.g. `input`)
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
Two host-side mechanisms cover it:
- **pam_exec** — a script in the login stack adds the user to the local group for
the session:
```sh
#!/bin/bash
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
```
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
`/etc/pam.d/common-session`.
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
members regardless of the local GID.
### Meta groups
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
implicit membership and are resolved by the directory only.
---
## 9. Downstream-app consumption guide
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
membership from LDAP and interprets it as follows:
1. **Discover the user's groups** — bind with the user's credentials (or use a
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
the `memberOf` reverse attribute on the user's entry.
2. **Match each group to a scope:**
- `god_admin` → the user is a global administrator.
- `{site}_super_admin` → site administrator for that site.
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
- `everyone` / `{site}_everyone` → the user is implicitly a member.
3. **Interpret the last segment:**
- `admin` → full control of that resource.
- `access` → read/use.
- anything else → a capability **you** define; act on it or ignore it.
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
`{site}_app_emby_emby_admin` administers Emby.
The app must **never** treat an unknown last segment as `admin` or `access`.
---
## 10. Migration from the legacy `app_*` groups
The current global groups (`app_sso_admin`, `app_super_admin`,
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
| Legacy | New |
| :--- | :--- |
| `app_super_admin` | `god_admin` |
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
| `app_sso_directory_admin` | `S_app_sso_admin` |
| `app_jump_admin` | `S_app_jump_admin` |
During the transition the legacy groups may be kept as short-lived aliases that
resolve to the same effective permission; once everything is moved, remove them.
---
## 11. The management consoles are apps
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
self-consistent: the SSO is "just another app."
+19 -10
View File
@@ -1,7 +1,7 @@
title: theta-env
title: theta-suite
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses.
url: "https://theta42.github.io"
baseurl: "/theta-env"
baseurl: "/theta-suite"
logo: /assets/img/theta42.svg
lang: en_US
@@ -10,10 +10,10 @@ plugins:
- 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_url: https://github.com/theta42/theta-suite
zip_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.tar.gz
repository_name: theta42/theta-suite
nav:
- title: Home
@@ -25,11 +25,20 @@ nav:
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Standalone
page: /standalone.html
icon: fa-puzzle-piece
- title: Secrets
page: /secrets.html
icon: fa-key
- title: SSO Manager
page: /sso/
icon: fa-users
- title: Proxy
page: /proxy/
icon: fa-shield-halved
- title: Jump Host
page: /jump-host/
icon: fa-terminal
- title: Changelog
url: https://github.com/theta42/theta-env/blob/master/CHANGELOG.md
url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md
icon: fa-list
defaults:
+164 -71
View File
@@ -1,77 +1,131 @@
---
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.
description: How theta-suite composes the SSO Manager, proxy, jump host, and ldap-client around a shared OpenBao secrets store — the OIDC/LDAP/secrets wiring setup.sh generates from one domain.
---
# Architecture
[← Back to Home](index.html)
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.
theta-suite is a **composition** repo: it builds four applications from their
git submodules and adds the glue that wires them together — plus a shared
[OpenBao](https://openbao.org/) secrets store — on one Docker network. It
does not fork or patch the components; it composes and configures them.
---
## The three repos
## Components
| Repo | Role |
| Repo / image | Role |
|------|------|
| [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node) | OIDC provider + OpenLDAP directory + web UI. All-in-one image (`Dockerfile.openldap`). |
| [`theta42/proxy`](https://github.com/theta42/proxy) | OIDC-protected reverse proxy (OpenResty + Node mgmt app + Redis). All-in-one image (`Dockerfile`). |
| `theta42/theta-env` (this repo) | Composes the two on one Docker network + automates first-run wiring. |
| [`theta42/jump-host`](https://github.com/theta42/jump-host) | Directory-driven SSH jump host (sshd + Node web UI). Image (`Dockerfile`). |
| [`theta42/ldap-client`](https://github.com/theta42/ldap-client) | Enrolls real Linux hosts into the directory (SSSD + AuthorizedKeysCommand). Also the opt-in `ldap-test-host` fixture. |
| `quay.io/openbao/openbao` | Central secrets store (Vault fork), KV-v2 at `secret/`. |
| `theta42/theta-suite` (this repo) | Composes all of the above on one network + automates first-run wiring. |
The two projects are pinned as **git submodules**. `git clone --recursive`
fetches all three in one step; `git submodule update --remote` bumps them.
The four applications are pinned as **git submodules**; OpenBao uses the
upstream image. `git clone --recursive` fetches the submodules in one step;
`git submodule update --remote` bumps them.
---
## The two containers
## The stack
```
┌──────────────────────────────────────────────┐
│ your browser / apps / direct LDAP clients
└───────────────┬──────────────────────────────┘
│ https (:443) ldaps (:636)
┌─────────▼─────────┐
proxy container │ OpenResty :80/:443/:4443
│ (OIDC + LDAP) │ Node mgmt app :3000 (localhost only)
bundled Redis (127.0.0.1:6379)
└─────────┬─────────┘
┌─────────────┼────────────────────────────┐
│ ldaps:636 │ http:3001 (internal) │ OIDC token + userinfo
│ (docker net)│ (docker net, not published)│ (server-to-server)
┌──────────────────────────────┐ │
sso-manager container │◄──────────────────
│ OIDC provider (Express) │ bundled Redis (127.0.0.1:6379)
│ OpenLDAP (slapd) │ web UI :3001 (localhost only)
│ ldaps :636 (published) │
───────────────────────────────┘
│ ldaps :636 (published to host) — legacy apps bind directly
┌──────────────────────────────┐
│ legacy apps (Gitea, Emby, …)
└──────────────────────────────┘
──────────────────────────────────────────────────────────┐
browser / OIDC apps │ SSH clients │ Linux hosts
│ │ │ (PAM/SSSD, sudo, keys) │
└────────┬────────────┴──────┬──────┴───────────┬───────────┘
https (:443) ssh (:2222) ldaps (:636)
│ │
┌────────▼────────┐ ┌──────────▼────────┐ │
proxy jump-host
│ OpenResty │ │ sshd :2222 │ │
│ :80/:443/:4443 │ │ web UI :3002 │ │
│ mgmt app :3000 │ └────────┬──────────┘ │
└────────┬─────────┘ │ OIDC + LDAP │
│ http:3001 (internal)│ via sso-manager
▼ ▼ ▼
┌───────────────────────────────────────────────────────
│ sso-manager (Express + OpenLDAP + Redis) │
│ OIDC provider + LDAP directory │
web UI :3001 (internal) ldaps :636 (published)
└───────────────────────────────────────────────────────┘
▲ loads secrets at boot (scoped token each)
┌───────────┴───────────────────┐
│ openbao (KV-v2 at secret/) │ ← central secrets store
│ :8200 (internal) │ per-user + per-app KV
│ :8080 (operator UI/API)
└──────────────────────────────
ldap-client — enrolls real Linux hosts into the directory above
(PAM/SSSD login, sudo, SSH-key serving); also the
`ldap-test-host` fixture (opt-in: `--profile ldap-test`).
```
Both containers bundle their **own Redis** (the proxy hardcodes `127.0.0.1:6379`
in three places that ignore config; the SSO's models default to the same). Two
redis instances is the no-source-patch path and is fine at this scale.
All four services bundle their **own Redis** (sso-manager, proxy, jump-host
each run a 127.0.0.1:6379 instance) and share the `openbao` secrets store.
Direct LDAP binds against `:636` are first-class — that's how Linux hosts do
PAM/SSSD login, sudo, and SSH-key serving, and how LDAP-native apps
authenticate — not a fallback path.
### What's exposed, what's not
| Port | On host? | Purpose |
|------|----------|---------|
| `443` (proxy) | **yes** | the public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
| `80` (proxy) | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
| `4443` (proxy) | yes (optional) | alt HTTPS listener |
| `3000` (proxy) | localhost only | proxy mgmt UI/API (first-run convenience; fronted by 443 normally) |
| `636` (sso) | yes | LDAPS for legacy direct-LDAP clients |
| `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) |
| Port | Service | On host? | Purpose |
|------|---------|----------|---------|
| `443` | proxy | **yes** | public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
| `80` | proxy | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
| `4443` | proxy | yes (optional) | alt HTTPS listener |
| `3000` | proxy | localhost/LAN | proxy mgmt UI/API (fronted by 443 normally) |
| `3001` | sso-manager | localhost/LAN | SSO web UI (fronted by the proxy normally) |
| `636` | sso-manager | **yes** | LDAPS for direct-LDAP clients (Linux hosts, LDAP-native apps) |
| `389` | sso-manager | **no** | plain LDAP — internal only (app↔slapd over localhost) |
| `2222` | jump-host | **yes** | SSH front door |
| `3002` | jump-host | **yes** | jump-host web UI/API |
| `8080` | openbao | yes | OpenBao UI/API for the operator (apps use `openbao:8200` internally) |
---
## Secrets (OpenBao)
Every component loads its secrets from one OpenBao instance at boot, not
from scattered config files. OpenBao runs as the `openbao` container
(`http://openbao:8200` on theta-net, KV-v2 at `secret/`); each app gets a
**scoped token** (never the root token) whose OpenBao policy confines it to
the paths it needs:
| Service | env var | Policy | Access |
|---------|---------|--------|--------|
| sso-manager | `SSO_VAULT_TOKEN` | `sso-broker` | `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; also mints per-user + per-app tokens |
| proxy | `PROXY_VAULT_TOKEN` | `proxy` | `secret/proxy/conf` (read) |
| jump-host | `JUMP_VAULT_TOKEN` | `jump-host` | `secret/jump-host/conf` (read) |
At boot each app calls `@simpleworkjs/bao-conf`'s `init()`, which deep-merges
its OpenBao path over the file-loaded `@simpleworkjs/conf` object — so OpenBao
is authoritative at runtime, with the `./config/*-secrets.js` file kept only as
an operator-edited seed and a fail-soft fallback (`init()` is fail-soft, so the
app still boots from the file if OpenBao is unreachable). The proxy and
jump-host consume `conf.oidc.clientSecret` at `require` time, so `init()`
runs *before* their models load (see each app's `bin/www`).
Beyond app config, OpenBao holds:
- **Per-user secret storage** — `secret/users/<uid>/*`, browsed and edited in
the SSO UI's **My Secrets** page. Each user is confined to their own
namespace by a `user-<uid>` policy; admins see all of `secret/`.
- **External-app tokens** — an admin mints a scoped `app-<name>` token
(confined to `secret/apps/<name>/*`) from the SSO UI's **Apps** tab, so an
external app can read its own secrets over the OpenBao HTTP API.
`setup.sh` creates the policies + a `sso-broker` token role and mints the
per-app tokens on first run; the root token stays in `setup.env` for
seeding/maintenance only and is never passed to a service container. Full
details — the policy model, the `secret/apps/<app>/conf` convention, `curl`
+ Node examples, and the operator rotation procedure — are in
[Secrets](secrets.html).
---
@@ -82,7 +136,14 @@ actual work, running **inside the sso-manager container** (bind-mounted
read-only from this repo). It's deliberately self-contained — only Node
built-ins (`child_process`, `crypto`, `fs`) + global `fetch`, and it reads its
inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets.js`
(not from env):
(not from env).
OpenBao comes up first: `setup.sh` initializes and unseals it, writes the
policies and the `sso-broker` token role, mints the per-app scoped tokens into
`setup.env`, and idempotently seeds `secret/sso-manager/conf`,
`secret/proxy/conf`, and `secret/jump-host/conf` from the corresponding
`./config/*-secrets.js` files. The app containers then start with their scoped
`VAULT_TOKEN`. The SSO/LDAP/OIDC wiring that follows:
1. **Build + start sso-manager**, wait for `/health`.
2. **LDAP service account**`ldapadd` `cn=ldapclient,ou=people,<base>` (an
@@ -97,14 +158,16 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
5. **Register the proxy as an OIDC client** via `POST /api/oauth/client` (gated
by `app_sso_oauth_admin`, satisfied by step 3). The SSO **generates** the
`client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`**
(the sso-manager mounts `./config` read-write for this; the proxy mounts it
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 points
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf`
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file.
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`
and into OpenBao at `secret/proxy/conf`** (the sso-manager mounts `./config`
read-write for this; the proxy mounts it 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 + jump-host**, wait for `/health`. Each
entrypoint points `CONF_SECRETS` at its `./config/*-secrets.js`, then
`@simpleworkjs/bao-conf` overlays the OpenBao path over it (the OAuth
clientSecret + LDAP bind creds come from OpenBao at runtime).
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
@@ -120,20 +183,25 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
`setup.sh` then prints the first-admin login + the public URLs.
### How config reaches the apps (no `.env`)
### How config reaches the apps
All config and secrets live in `./config/` (gitignored, bind-mounted). Each
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0)
at its file early, before the app starts:
Config and secrets live in two layers: an operator-edited
`./config/*-secrets.js` file (gitignored, bind-mounted) and the OpenBao
overlay over it. Each entrypoint points the `CONF_SECRETS` env var
(`@simpleworkjs/conf` >= 1.2.0) at its file early, before the app starts:
```
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW)
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
CONF_SECRETS=/config/jump-secrets.js (jump-host, ./config RO)
```
`@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
env`, where **env beats the secrets file**. Then `@simpleworkjs/bao-conf`
deep-merges the app's OpenBao path over the result at boot — OpenBao is the
authoritative runtime layer; the file is the seed and fail-soft fallback. So
compose passes **no `app_*` config env vars** (only `NODE_ENV`, `NODE_PORT`,
`VAULT_ADDR`, and a scoped `VAULT_TOKEN`) — that keeps the secrets file + OpenBao
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.
@@ -151,12 +219,15 @@ end-to-end.
## Idempotency
Re-running `./setup.sh` converges to `./config/`:
Re-running `./setup.sh` converges to `./config/` + OpenBao:
- The LDAP service account + admin passwords are **reset to `./config/`**.
- Group membership is ensured (add is a no-op if already a member).
- The OAuth client is kept if `proxy-secrets.js` already holds its creds;
created or rotated otherwise, and the new creds written back.
created or rotated otherwise, and the new creds written back (to the file
and to OpenBao).
- OpenBao policies, token role, per-app tokens, and `secret/<app>/conf` seeds
are ensured (created if absent, left alone if present).
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
compose down`, or after restoring from backup.
@@ -165,17 +236,39 @@ compose down`, or after restoring from backup.
## Backups and restore
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
`./setup.sh` auto-snapshots `./config/` + LDAP + all the Redis instances to
`./backups/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`)
and survives recreation; `down -v` wipes them. Redis is persisted with AOF +
RDB on those volumes. For the full manual-backup + restore runbook (full /
Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and
restore* section of the [README](https://github.com/theta42/theta-env#backups-and-restore).
Quick LDAP backup:
default 5). State lives on named volumes (`ldap-data`, `ldap-certs`,
`sso-data`, `proxy-data`, `proxy-cache`, `proxy-logs`, `jump-data`,
`jump-redis-data`, `openbao-data`) and survives recreation; `down -v` wipes
them. Redis is persisted with AOF + RDB on those volumes. For the full
manual-backup + restore runbook (full / Redis-only / LDAP-only, with the
AOF-vs-RDB note), see the *Backups and restore* section of the
[README](https://github.com/theta42/theta-suite#backups-and-restore). OpenBao
holds the live secrets, so back up its volume too (`<project>_openbao-data`,
where `<project>` is your clone directory name — `theta-suite` for a fresh
clone). Quick LDAP backup:
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
```
---
## Plugin Ecosystem
The SSO Manager utilizes a dynamic plugin registry (`nodejs/services/plugin_registry.js`) that automatically loads any `.js` file placed in the `nodejs/plugins/<category>` folders.
### Discovery Plugins
Discovery plugins (e.g., `nmap.js`, `proxmox.js`, `docker.js`) run on a defined cron schedule to sync external assets into the centralized directory catalog.
### Messaging Plugins
Messaging plugins (e.g., `twilio.js`, `webhook.js`) provide on-demand delivery capabilities for alerts, 2FA tokens, and notifications.
- **Universal REST Webhook:** Sends custom JSON payloads to platforms like Slack, Teams, or custom API endpoints securely.
- *Discord Example:* To send alerts to a Discord channel, create a new plugin instance of type "Universal REST Webhook". Set the **Webhook URL** to your Discord webhook URL (e.g., `https://discord.com/api/webhooks/...`), the **HTTP Method** to `POST`, and the **Payload Template** to `{"content": "Alert for {{to}}: {{message}}"}`. Leave the Headers and API Secret blank.
- **Twilio SMS:** Sends standard SMS codes.
- **Fallback:** If no messaging plugins are enabled, the system falls back to the legacy `voipms` integration configured in the SSO secrets.
Secrets belonging to plugins are automatically pushed to OpenBao (`secret/plugins/<id>/conf`) and are never written to the local database, following the global secrets architecture.
[← Back to Home](index.html)
+39 -22
View File
@@ -4,20 +4,24 @@ 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
# theta-suite
The whole theta42 identity + access stack in one repo, brought up with a
single command — for home labs and small businesses.
The whole theta42 identity, access, and secrets stack in one repo, brought up
with a single command — for home labs and small businesses.
It wires together two projects that already work on their own —
It composes four applications around a shared secrets store:
[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`. An optional third component, the
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
access to your machines through one public entry point.
LDAP directory), [Proxy](https://theta42.github.io/proxy/) (an OIDC-protected
reverse proxy that can also look users up directly in LDAP),
[Jump Host](https://theta42.github.io/jump-host/) (directory-driven SSH access
through one public entry point), and
[ldap-client](https://theta42.github.io/ldap-client/) (enrolls your Linux
hosts into the directory for PAM/SSSD, sudo, and SSH keys). All of them read
their secrets at boot from [OpenBao](https://openbao.org/), the central secrets
store. `setup.sh` automates the fiddly part: registering the proxy as an OIDC
client of the SSO, pointing every component at the right LDAP directory and
the OpenBao token it needs, and generating hostnames and secrets from one
`setup.env`.
## Screenshots
@@ -31,9 +35,9 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
## 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
The components are designed to integrate — they're only 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
@@ -46,9 +50,20 @@ snapshots state before every rebuild.
- **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
LDAP-native apps authenticate against the same directory.
- **SSH Jump Host** *(optional)*`ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
- **Hierarchical groups & permissions** — every adopted host and app gets its own
`admin`/`access`/`capability` groups, generated from the Directory; they double
as real POSIX groups for sudo/SSH. See
[Group & Permission Model](GROUPS.html).
- **ldap-client** — enroll Linux hosts into the directory (PAM/SSSD login,
sudo, SSH keys); the host inventory shows up in the SSO UI and drives
jump-host routing.
- **SSH Jump Host**`ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
or an interactive picker; access is driven by directory group membership, with
a web UI for audit + metrics. Enable with `CFG_JUMP_HOST_ENABLED=true`.
a web UI for audit + metrics.
- **Central secrets (OpenBao)** — every component loads its secrets from one
[OpenBao](https://openbao.org/) instance at boot; each user gets personal
secret storage, and admins mint scoped tokens for external apps. See
[Secrets](secrets.html).
- **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.
@@ -57,16 +72,16 @@ snapshots state before every rebuild.
## Get it
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
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/`. For the full config reference,
architecture, and running each project standalone, see the
**[GitHub repository](https://github.com/theta42/theta-env)**.
architecture, see the
**[GitHub repository](https://github.com/theta42/theta-suite)**.
## Related projects
@@ -74,5 +89,7 @@ architecture, and running each project standalone, see the
provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it.
- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump
host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`).
- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump
host this stack brings up.
- **[ldap-client](https://theta42.github.io/ldap-client/)** — enrolls Linux
hosts into the directory this stack serves.
+24
View File
@@ -0,0 +1,24 @@
# Documentation
This directory is the GitHub Pages documentation site for the Jump Host project.
**Live site:** https://theta42.github.io/jump-host/
## Pages
- `index.md` — overview and quick start
- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
- `architecture.md` — how auth, access resolution, key injection, and bridging work
- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
## Local preview
```bash
gem install jekyll bundler
cd docs && jekyll serve
# http://localhost:4000/jump-host/
```
## Updating
Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
+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/favicon.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>
+122
View File
@@ -0,0 +1,122 @@
---
layout: default
title: Architecture
description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
---
# Architecture
The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
as both an SSH **server** and **client**) with two faces: the SSH front door
(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
own — identity, authorization, and onward credentials all come from the shared
directory.
```
┌────────────────────── jump host ──────────────────────┐
ssh │ ssh2 Server (:2222) │ ssh2 Client
─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
│ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
│ 4. bridge channels ◀───────────────────────────────▶ │
│ web UI/API (:3002) ──▶ audit + metrics (redis) │
└───────────────────────────────────────────────────────┘
```
## 1. Inbound authentication
When a user connects, the jump host authenticates them against LDAP:
- **Public key** — it looks up the user's `sshPublicKey` values in the directory
and matches the offered key (handling ssh2's probe-then-sign two-phase
publickey auth). The jump host's *own* injected key (identified by its comment
marker) is deliberately excluded from this match — only the jump host may hold
that private key, so accepting it inbound would be a bypass.
- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
`off` (keys only — recommended for a public host), `local` (passwords only
from loopback/RFC1918 clients, keys-only from the internet), or `all`.
Every attempt — success or failure, with method and reason — is audited.
## 2. Access & target resolution
The hosts a user may reach are computed from the directory, not a local list:
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
2. For each group, the SSO's
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
unioned and filtered to `kind: host`.
Each host's dial address is `metadata.ip` (or the hostname from
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
briefly per user and shared by both the grammar path and the TUI picker.
Target matching tries, in order: exact slug → `host_`-prefixed slug → display
name → IP → address hostname. A raw IP that isn't an accessible directory host
is refused unless explicitly allowed.
> The directory auto-creates `<slug>_access` / `<slug>_admin` groups for every
> host and service (see the SSO's
> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
> docs), which is exactly what this authorization reads.
## 3. Upstream Authentication (PKI or LDAP Keys) {#upstream-authentication}
To connect downstream *as the user* without asking them for a password, the jump host uses one of two methods (configured in `conf.ssh`):
**Option A: PKI Certificates (Recommended)**
The jump host securely calls the OpenBao (Vault) SSH Secrets Engine API to request a short-lived (e.g. 5-minute), signed SSH certificate for the target user.
- **Zero Touch on Target:** The target host simply trusts the OpenBao CA (`TrustedUserCAKeys /etc/ssh/ca.pub`). No public keys are synced or managed.
- **Ephemeral:** The certificate expires automatically.
- **Transparent:** The jump host passes `cert: signedCert` to `ssh2.Client`, authenticating instantly.
**Option B: Legacy LDAP Key Injection**
If PKI is not configured, the jump host falls back to its legacy method: it holds **one** keypair. On a user's first connection, the jump host appends its own public key to that user's `sshPublicKey` attribute in LDAP (comment-marked) so the downstream host's `AuthorizedKeysCommand` will accept it, then connects with its private key.
- Idempotent: the key is added once; a redis flag skips the LDAP round-trip afterwards.
- The jump host's bind account needs **write access** to the `sshPublicKey` attribute.
- Because the marker key is excluded from inbound auth (step 1), it grants only the jump host's onward path.
## 4. Bridging
Once the upstream connection is ready, the jump host splices SSH channels
between the two connections:
- **shell / exec** — piped both ways, with window-change and exit-status
forwarded.
- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
unchanged.
- Channel requests that arrive before the upstream is ready are buffered and
replayed, so nothing is dropped during the connect.
- The downstream host key's SHA256 fingerprint is recorded in the audit event
(trust-on-use in v1).
Byte counts per direction are tallied cheaply for the audit record.
## Web UI, API & audit
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
`auth.adminGroups`. It exposes:
- `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions
- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
- `GET /api/metrics` — counters (total, failures, top users/hosts)
Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Where it sits in the stack
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
jump host reads.
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
connects into.
- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
under TLS.
- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
+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;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+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

+102
View File
@@ -0,0 +1,102 @@
---
layout: default
title: Connecting
description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
---
# Connecting
You reach a downstream host two ways: name the target in your username, or log
in plain and pick it from a menu. Either way you authenticate **once**, to the
jump host, with your directory credentials.
## The username grammar
```
{uid}_-_{target}
```
- `{uid}` — your directory username.
- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
`web01`), the host's **display name**, its **IP**, or the hostname in its
directory `address`.
```bash
ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
```
If the target matches a host your directory groups grant, you're bridged
straight to its `sshd` — same as if you'd SSH'd directly, but through the
audited jump host.
## SFTP / WinSCP / scp
Because the whole route is encoded in the username, file transfer tools that
only take one connection string work with no extra configuration:
```bash
sftp -P 2222 alice_-_web01@jump.example.com
scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
```
**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
(browse, upload, download, rename) work normally.
## The interactive picker
Log in with just your username and you get a TUI list of every host you can
reach:
```bash
ssh alice@jump.example.com
```
- **↑ / ↓** move the selection
- **type** to filter the list incrementally
- **Enter** connect to the highlighted host
- **number keys** jump straight to that row
- **q** or **Ctrl-C** to quit
Pick a host and you're bridged into it. The picker only ever lists hosts your
directory access allows — it doubles as "what can I reach from here?"
## What you can reach
The set of hosts is computed per login: your LDAP group memberships intersected
with the SSO directory's hosts (via the `host_<name>_access` groups the
directory auto-creates for each machine). To get access to a new host, an admin
adds you to that host's access group in the SSO — nothing on the jump host
changes.
Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default.
## Authentication
The jump host authenticates **you** against the directory:
- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
normal SSH key; the client picks it automatically.
- **Password** — your directory password (LDAP bind). Password auth is often
restricted to local networks or disabled entirely on a public jump host
(keys-only) — check with your operator.
You never manage a separate credential for the downstream host: the jump host
handles onward authentication for you (see
[Architecture](architecture.html#per-user-key-injection)).
## First connection to a host
The very first time you reach a given downstream host, the jump host provisions
its access key for you behind the scenes. If that first attempt races the
directory's key-cache refresh you may see a brief
```
jump-host: first-time key propagation, retrying…
```
and it reconnects automatically. Subsequent connections are immediate.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+116
View File
@@ -0,0 +1,116 @@
---
layout: default
title: Home
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to.
---
# Jump Host
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
stack. Users SSH into **one** public host and land on any downstream host
they're entitled to — authenticated against the shared LDAP directory,
authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
inventory graph, and audited end to end.
No per-host accounts, no distributing keys, no VPN. The same people who log in
to your SSO are the people who can reach your machines — and only the machines
their directory groups grant.
Part of the theta42 self-hosted identity stack, alongside
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
[Proxy](https://theta42.github.io/proxy/), composable with one command via
[theta-env](https://theta42.github.io/theta-env/).
## Screenshots
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
## Two ways to connect
**Direct (WinSCP/SFTP-friendly):**
```bash
ssh alice_-_web01@jump.example.com
sftp -P 2222 alice_-_web01@jump.example.com
```
The username grammar is `{uid}_-_{target}``target` is a directory host slug
(with or without the `host_` prefix), a bare hostname, or an IP. One username
string, no interactive step, so it works cleanly in WinSCP and scripts.
**Interactive picker:**
```bash
ssh alice@jump.example.com
```
A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
filter, Enter to connect.
See **[Connecting](connecting.html)** for the full usage guide.
## Why a jump host (and why this one)
A bastion/jump host is the standard way to give SSH access to internal machines
through a single audited entry point. What's usually painful is *authorization*
and *credentials*: who may reach which host, and how the bastion authenticates
onward without you copying keys everywhere.
This jump host answers both from your directory:
- **Authorization is your directory graph.** The hosts you can reach are the
union of your LDAP groups × the SSO's inventory (the `host_<name>_access`
groups the directory already auto-creates). Add someone to a group; they can
reach the host. No bastion-side allow-list to maintain.
- **Onward auth is automatic.** The jump host holds one key and injects its
public half into your `sshPublicKey` on first use, then connects downstream
**as you**. Downstream hosts already serve keys from LDAP (via
[ldap-client](https://github.com/theta42/ldap-client)'s
`AuthorizedKeysCommand`), so nothing downstream needs configuring.
## Features
- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
SFTP included (WinSCP works)
- **Interactive TUI host picker** on plain login, scoped to your access
- **LDAP inbound auth** — public key or password (keys-only policy recommended
for a public host)
- **Directory-driven access** — reachable hosts come from the SSO inventory, not
a static list
- **Per-user key injection** — no downstream changes, no key distribution
- **Shell, exec, and SFTP** bridging
- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
audit log, per-user/per-host counters
- **Full audit trail** — who, target, method, result, bytes, duration, and the
downstream host-key fingerprint
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
installer, or bundled in theta-env
## Get it
```bash
git clone https://github.com/theta42/jump-host.git
cd jump-host
cp secrets.js.example config/jump-secrets.js # then edit it
docker compose up -d --build
```
For bare-metal and the bundled theta-env
option, see **[Installation](installation.html)**.
## Related projects
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
directory + OIDC provider + the inventory graph this jump host reads.
- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
same identity; fronts this jump host's web UI.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
jump host included, with one command.
+39
View File
@@ -0,0 +1,39 @@
# Documentation
This directory contains the GitHub Pages documentation site for the Proxy project.
**Live site:** https://theta42.github.io/proxy/
## Pages
- `index.md` - Home page with project overview
- `installation.md` - Installation and setup guide
- `api.md` - Complete API reference
- `architecture.md` - System architecture and design
- `contributing.md` - Development and contribution guide
## Local Preview
To preview the site locally:
```bash
# Install Jekyll (one-time setup)
gem install jekyll bundler
# Run local server
cd docs
jekyll serve
# View at http://localhost:4000/proxy/
```
## Theme
The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
## Updating Documentation
1. Edit markdown files in this directory
2. Commit and push to master branch
3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
4. Changes visible at https://theta42.github.io/proxy/
+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/favicon.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>
+972
View File
@@ -0,0 +1,972 @@
---
layout: default
title: API Reference
description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
---
# API Documentation
[← Back to Home](index.html)
All API endpoints require authentication unless otherwise noted. Three
authentication methods are supported:
- **`auth-token` header** — a browser-session token from `POST /api/auth/login`
or the OIDC flow (below).
- **`Authorization: Bearer <token>` header** — a self-service API token (PAT,
see [API Tokens](#api-tokens)), for scripts/CI without a browser session.
- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO
(`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)),
users can log in via `GET /api/auth/oidc/start` instead of posting a
username/password.
The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` /
`conf.ldap`) for looking up/validating users, independent of the OIDC flow —
see DEPLOYMENT.md for the full configuration reference.
Authenticated requests also carry **RBAC** (role-based access control):
global admins can manage everything; other users are scoped to `viewer` or
`manager` rights on specific domains via [Permissions](#permissions) and
[Groups](#groups).
Base URL: `https://your-proxy-host.com/api`
---
## Authentication
### Login
**POST** `/api/auth/login`
Authenticate a user and receive an auth token.
```bash
curl -H "Content-Type: application/json" \
-X POST \
-d '{"username": "myuser", "password": "mypassword"}' \
https://proxy-host.com/api/auth/login
```
**Responses:**
- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}`
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}`
### Logout
**ALL** `/api/auth/logout`
Invalidate the current auth token.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/auth/logout
```
**Responses:**
- `200` `{"message": "Bye"}`
### OIDC Login (start)
**GET** `/api/auth/oidc/start`
Begin the OIDC authorization-code flow: creates a PKCE + state challenge and
redirects the browser to the configured SSO's authorize endpoint. Only
available when `conf.oidc.enabled` is true.
**Query Parameters:**
- `redirect` - Internal path to return to after login (optional; sanitized to same-origin)
```bash
curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts"
```
**Responses:**
- `302` Redirect to the SSO's authorization endpoint
- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}`
### OIDC Callback
**GET** `/api/auth/oidc/callback`
Redirect target for the SSO after login. Validates the one-time `state`,
exchanges the authorization `code` for tokens, reads identity from the
userinfo endpoint, establishes a session, and redirects the browser back to
the login page with the app's own `auth-token` in a URL fragment.
**Query Parameters:**
- `code` (required) - Authorization code from the SSO
- `state` (required) - State value from the `start` step
```bash
# Not called directly — the SSO redirects the browser here after login.
```
**Responses:**
- `302` Redirect to `/login#token=...&redirect=...`
- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state
---
## API Tokens
Self-service personal access tokens (PATs) for scripting/CI without a browser
session. Every endpoint is owner-scoped: a user only sees/manages tokens they
created. Mounted at `/api/api-token`.
### List API Tokens
**GET** `/api/api-token`
List the current user's API tokens.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}`
### Create API Token
**POST** `/api/api-token`
Create a new API token. The raw token string is only returned once, at
creation.
**Parameters:**
- `name` (required) - Display name
- `description` (optional)
- `expires_in_days` (optional) - `0` or omitted means no expiry
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ci", "expires_in_days": 90}' \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": {...}, "token": "prx_<id>_<secret>", "message": "API token 'ci' created. Save it now — it will not be shown again."}`
### Get API Token
**GET** `/api/api-token/:id`
Get a token's metadata (not the raw secret, which is never stored/returned again).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}}`
- `403` Not your token
### Update API Token
**PUT** `/api/api-token/:id`
Update a token's name/description/expiry.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "ci-updated"}' \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}`
### Delete (Revoke) API Token
**DELETE** `/api/api-token/:id`
Revoke a token immediately.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"id": "<id>", "message": "API token 'ci' revoked."}`
### Rotate API Token
**POST** `/api/api-token/:id/rotate`
Issue a new secret for an existing token (same id, new raw value shown once).
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/api-token/<id>/rotate
```
**Responses:**
- `200` `{"token": "prx_<id>_<new-secret>", "message": "API token 'ci' rotated. Save it — it will not be shown again."}`
---
## Users
All user endpoints require authentication. `GET /me` and `PUT /password`
(self-service) work for any authenticated user; everything else (listing,
creating, deleting users, resetting another user's password) requires global
admin.
### List Users
**GET** `/api/user`
Get list of all users. Admin only.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user
```
**Query Parameters:**
- `detail` - Include full user details (optional)
**Responses:**
- `200` `{"results": ["user1", "user2"]}`
- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`)
- `403` Not an admin
### Get Current User
**GET** `/api/user/me`
Get the currently authenticated user's identity and effective RBAC rights
(drives the web UI's nav/button gating).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user/me
```
**Responses:**
- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}`
### Create User
**POST** `/api/user`
Create a new local user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "newuser", "password": "newpassword"}' \
https://proxy-host.com/api/user
```
**Responses:**
- `200` User created successfully
- `403` Not an admin
- `409` Username already exists
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords)
### Delete User
**DELETE** `/api/user/:username`
Delete a user account. Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/user/olduser
```
**Responses:**
- `200` `{"username": "olduser", "results": ...}`
- `403` Not an admin
- `404` User not found
### Change Password (Self)
**PUT** `/api/user/password`
Change the password for the currently authenticated user.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `422` Weak password rejected by the password policy
### Change Password (Other User)
**PUT** `/api/user/password/:username`
Change the password for another user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password/otheruser
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `403` Not an admin
- `404` User not found
---
## Permissions
RBAC: grants a `viewer` or `manager` role to a user or group, either globally
or scoped to one domain. Global-admin-only. Mounted at `/api/permission`.
### List Permissions
**GET** `/api/permission`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}`
### List Permission Subjects
**GET** `/api/permission/subjects`
Autocomplete source for the "Subject" field: known usernames plus known group
names (local groups, groups already used in permissions, and groups from
`conf.auth.adminGroups` / `conf.auth.groupRoleMap`).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission/subjects
```
**Responses:**
- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}`
### Create Permission
**POST** `/api/permission`
Grant a role to a subject.
**Parameters:**
- `subjectType` (required) - `user` or `group`
- `subject` (required) - username or group name
- `role` (required) - `viewer` or `manager`
- `scope` (required) - `global` or `domain`
- `domain` (required if `scope` is `domain`)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}`
- `422` Validation error
### Delete Permission
**DELETE** `/api/permission/:id`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/permission/<id>
```
**Responses:**
- `200` `{"message": "Permission <id> removed."}`
---
## Groups
Local groups (independent of any SSO/LDAP groups) used as subjects for
permission grants. Global-admin-only. Mounted at `/api/group`.
### List Groups
**GET** `/api/group`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}`
### Create Group
**POST** `/api/group`
**Parameters:**
- `name` (required)
- `members` (optional) - array of usernames
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ops", "members": ["alice"]}' \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"message": "Group \"ops\" created.", ...}`
### Delete Group
**DELETE** `/api/group/:name`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops
```
**Responses:**
- `200` `{"message": "Group \"ops\" removed."}`
### Add Group Member
**POST** `/api/group/:name/members`
**Parameters:**
- `username` (required)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "bob"}' \
https://proxy-host.com/api/group/ops/members
```
**Responses:**
- `200` `{"message": "Added \"bob\" to \"ops\".", ...}`
### Remove Group Member
**DELETE** `/api/group/:name/members/:username`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops/members/bob
```
**Responses:**
- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}`
---
## Hosts
Manage proxy host configurations.
### List Hosts
**GET** `/api/host`
Get list of all configured hosts.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host
```
**Query Parameters:**
- `detail` - Include full host details (optional)
**Responses:**
- `200` `{"results": ["example.com", "*.wildcard.com"]}`
- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`)
### Get Host
**GET** `/api/host/:host`
Get configuration for a specific host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Lookup Host
**GET** `/api/host/lookup/:domain`
Test the host lookup algorithm (supports wildcard matching).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookup/sub.example.com
```
**Responses:**
- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}`
- `200` `{"string": "sub.example.com", "results": null}` (no match)
### Get Lookup Tree
**GET** `/api/host/lookupobj`
Get the internal lookup tree structure (for debugging).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookupobj
```
**Responses:**
- `200` `{"results": {"com": {"example": {...}}}}`
### Create Host
**POST** `/api/host`
Add a new host configuration.
**Parameters:**
- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`)
- `ip` (required) - Target IP address or FQDN
- `targetPort` (required) - Target port number (1-65535)
- `forcessl` (optional) - Force HTTPS redirect (default: true)
- `targetssl` (optional) - Use HTTPS to backend (default: false)
- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild`
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \
https://proxy-host.com/api/host
```
**Responses:**
- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}`
- `409` `{"name": "HostNameUsed", "message": "Host already exists"}`
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error
### Update Host
**PUT** `/api/host/:host`
Update an existing host configuration.
**Parameters:** Same as Create Host (all optional)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"ip": "192.168.1.20", "targetPort": 9000}' \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "\"example.com\" updated.", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
- `422` Validation error
### Delete Host
**DELETE** `/api/host/:host`
Remove a host configuration.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "example.com deleted", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Clear Host Cache
**DELETE** `/api/host/cache`
Remove all cached wildcard-subdomain host lookups. Cache entries are created on
demand when a wildcard host serves a subdomain; clearing them forces the next
request for each subdomain to be resolved fresh through the lookup tree.
Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/cache
```
**Responses:**
- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}`
### Renew Wildcard Certificate
**PUT** `/api/host/:host/renew`
Manually trigger wildcard certificate renewal.
```bash
curl -H "auth-token: your-token-here" \
-X PUT \
https://proxy-host.com/api/host/*.example.com/renew
```
**Responses:**
- `200` `{"message": "Requesting wildcard cert for *.example.com"}`
- `404` Host not found
---
## DNS Providers
Manage DNS provider integrations for wildcard SSL certificates.
### List DNS Providers
**GET** `/api/dns`
Get list of configured DNS providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns
```
**Query Parameters:**
- `detail` - Include full provider details (optional)
**Responses:**
- `200` `{"results": ["provider-id-1", "provider-id-2"]}`
### List Available Provider Types
**OPTIONS** `/api/dns`
Get list of supported DNS provider types and their configuration requirements.
```bash
curl -H "auth-token: your-token-here" \
-X OPTIONS \
https://proxy-host.com/api/dns
```
**Responses:**
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
### Create DNS Provider
**POST** `/api/dns`
Configure a new DNS provider.
**Cloudflare:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**DigitalOcean:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**PorkBun:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \
https://proxy-host.com/api/dns
```
**DuckDNS (free):**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \
https://proxy-host.com/api/dns
```
`subdomains` is a comma-separated list of the subdomains you've registered at
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
**Responses:**
- `200` `{"message": "\"provider-id\" added.", ...}`
- `422` Validation error or invalid API credentials
### Get DNS Provider
**GET** `/api/dns/:id`
Get a specific DNS provider configuration.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"item": "provider-id", "results": {...}}`
- `404` Provider not found
### Update DNS Provider
**PUT** `/api/dns/:id`
Update DNS provider configuration.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "Updated Name"}' \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "\"provider-id\" updated.", ...}`
- `404` Provider not found
### Delete DNS Provider
**DELETE** `/api/dns/:id`
Remove a DNS provider and all associated domains.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "provider-id deleted", ...}`
- `404` Provider not found
### List Domains
**GET** `/api/dns/domain`
List all domains from all configured providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain
```
**Query Parameters:**
- `detail` - Include full domain details (optional)
**Responses:**
- `200` `{"results": ["example.com", "test.com"]}`
### Get Domain
**GET** `/api/dns/domain/:domain`
Get details for a specific domain.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain/example.com
```
**Responses:**
- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}`
- `404` Domain not found
### Refresh Domains
**POST** `/api/dns/domain/refresh/:providerId`
Refresh the domain list from a DNS provider's API.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/domain/refresh/provider-id
```
**Responses:**
- `200` `{"results": ...}` Updated domain list
- `404` Provider not found
### Dynamic DNS
A-records kept automatically pointed at this box's public (WAN) IP. All
`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain
(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`.
#### Get Current Public IP
**GET** `/api/dns/dynamic/ip`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic/ip
```
**Responses:**
- `200` `{"ip": "203.0.113.5"}`
#### List Dynamic Records
**GET** `/api/dns/dynamic`
Lists records the caller may view (their own/granted domains, or all for admins).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}`
#### Create Dynamic Record
**POST** `/api/dns/dynamic`
Requires `manager` rights on the target domain. Applies the record immediately
against the current public IP (best-effort — failures are recorded in
`last_status` and retried by the scheduler).
**Parameters:**
- `domain` (required)
- `name` (required) - sub-label, or `@` for the apex
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"domain": "example.com", "name": "home"}' \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"message": "\"home.example.com\" added.", ...}`
- `403` Missing `manager` rights on the domain
- `422` Validation error
#### Refresh Dynamic Record
**POST** `/api/dns/dynamic/:id/refresh`
Force an immediate refresh of one record against the current public IP.
Requires `manager` rights on the record's domain.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/dynamic/<id>/refresh
```
**Responses:**
- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}`
- `403` Missing `manager` rights on the domain
#### Delete Dynamic Record
**DELETE** `/api/dns/dynamic/:id`
Stop managing a record. Requires `manager` rights on the record's domain.
Leaves the provider's A record in place at its last value.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/dynamic/<id>
```
**Responses:**
- `200` `{"message": "home.example.com removed.", ...}`
- `403` Missing `manager` rights on the domain
---
## Certificates
Retrieve SSL certificate information.
### Get Certificate
**GET** `/api/cert/:host`
Get the SSL certificate for a host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/cert/example.com
```
**Responses:**
- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information
- `404` Certificate not found
---
## Error Responses
All endpoints may return the following error responses:
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid
- `404` `{"name": "NotFound", "message": "..."}` - Resource not found
- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors
- `500` Internal server error
## Notes
- All timestamps are in milliseconds since epoch
- Authenticated endpoints accept either the `auth-token` header (browser
session / OIDC login) or an `Authorization: Bearer <token>` API token
- Host names support wildcards: `*` (single level) and `**` (multi-level)
- DNS providers are validated on creation - invalid API credentials will be rejected
- Wildcard certificates are automatically renewed 30 days before expiration
+291
View File
@@ -0,0 +1,291 @@
---
layout: default
title: Architecture
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
---
# Architecture
[← Back to Home](index.html)
> Looking for a plainer explanation of hosts, HTTPS, or the local
> permission model instead of internals? See
> [Hosts & HTTPS](concepts-hosts.html) and
> [Users, Groups & Permissions](concepts-access.html).
## System Overview
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
```
┌──────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────┬────────────────────────────────────┘
│ HTTPS/HTTP
┌──────────────────────────────────────────────────────────────┐
│ OpenResty/Nginx │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │
│ │ (lua-resty- │ │ (targetinfo. │ │ │ │
│ │ auto-ssl) │ │ lua) │ │ │ │
│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │
└────────────┬──────────────────┼───────────────────────────┬──┘
│ │ │
Let's Encrypt 1. Check Redis FIRST Backend
HTTP-01 2. Unix Socket (fallback) Services
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ Redis │ │ Node.js Application │
│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │
│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │
│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │
│ - SSL certs │ │ │ - scheduler │ │ │ │
│ - Auth tokens │ │ └──────────────┘ └─────────┘ │
└──────────────────────┘ └─────────┬────────────────────────┘
┌──────────────────────┐
│ DNS Providers │
│ - Cloudflare │
│ - DigitalOcean │
│ - PorkBun │
│ - DuckDNS (free) │
│ (DNS-01 challenges) │
└──────────────────────┘
```
## Component Details
### OpenResty/Nginx (Frontend)
**Responsibilities:**
- Accept incoming HTTP/HTTPS requests
- SSL termination using lua-resty-auto-ssl
- Host-based routing decisions (Redis-first lookup)
- Proxy requests to backend services
**Key Features:**
- HTTP-01 ACME challenge handling for automatic SSL
- Redis-first host lookup with Node.js fallback via Unix socket
- High-performance event-driven architecture
- Support for WebSocket connections
- Continues serving cached hosts even if Node.js is down
**Configuration Files:**
- `/etc/openresty/nginx.conf` - Main configuration
- `/etc/openresty/autossl.conf` - Let's Encrypt integration
- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration
- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module
### Node.js Application (Backend)
**Responsibilities:**
- API for host/user/DNS management
- Wildcard SSL certificate orchestration
- Host lookup tree maintenance
- User authentication and authorization
**Directory Structure:**
```
nodejs/
├── bin/www # Application entry point
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models
│ ├── host.js # Host configuration and lookup
│ ├── auth.js # Authentication logic
│ ├── user.js # User management
│ └── dns_provider/ # DNS provider implementations
├── routes/ # API endpoints
│ ├── host.js # Host CRUD operations
│ ├── dns.js # DNS provider management
│ ├── user.js # User management
│ ├── auth.js # Authentication (login + OIDC)
│ ├── permission.js # RBAC permission management
│ ├── group.js # Local group management
│ └── api_token.js # Self-service API (PAT) tokens
├── services/ # Background services
│ ├── host_lookup.js # Unix socket server
│ └── host_scheduler.js # Cert renewal scheduler
├── middleware/ # Express middleware
│ └── auth.js # Authentication middleware
└── utils/ # Utility modules
└── unix_socket_json.js # Unix socket server
```
### Redis (Data Store)
**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management.
**Stored Data:**
- Host configurations (domain, IP, port, SSL settings)
- User accounts and hashed passwords
- Authentication tokens
- SSL certificates (for wildcard domains)
- DNS provider credentials
- Domain-to-provider mappings
**Key Prefixes:**
```
proxy_Host_<hostname> # Host configuration
proxy_User_<username> # User account
proxy_AuthToken_<token> # Auth tokens
proxy_DnsProvider_<id> # DNS provider
proxy_Domain_<domain> # Domain info
<hostname>:latest # SSL certificate cache
```
## Request Flow
### Standard HTTP/HTTPS Request
1. **Client** sends HTTPS request to `app.example.com`
2. **OpenResty** receives request, terminates SSL
3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config
4. If **found in Redis**, jump to step 7 (Node.js not involved)
5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback
6. **Node.js** performs host lookup (supports wildcards), caches result in Redis
7. **OpenResty** proxies request to backend service using target IP and port
8. **Response** proxied back to client
**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers.
### Wildcard SSL Certificate Request
1. **User** creates wildcard host (`*.example.com`) via API
2. **Node.js** validates domain has DNS provider configured
3. **Let's Encrypt** DNS-01 challenge initiated
4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`)
5. **Let's Encrypt** validates TXT record
6. **Certificate** generated and stored in Redis
7. **DNS provider** cleans up TXT record
8. **Background scheduler** monitors expiration, renews 30 days before expiry
## Host Lookup Algorithm
The lookup tree enables sophisticated domain matching:
```
Input: "api.v1.example.com"
Tree Structure:
{
"com": {
"example": {
"*": { // Matches api.example.com
"#record": {...}
},
"v1": {
"api": { // Matches api.v1.example.com (exact)
"#record": {...}
}
}
}
}
}
Priority: Exact > Single wildcard (*) > Double wildcard (**)
```
**Wildcard Types:**
- `example.com` - Exact match only
- `*.example.com` - Matches `sub.example.com` (single level)
- `**.example.com` - Matches any depth (`sub.deep.example.com`)
- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com`
## Security Architecture
### Authentication Flow
1. User sends credentials to `/api/auth/login`
2. Credentials validated against stored hash (bcrypt)
3. Token generated and stored in Redis with TTL
4. Token returned to client
5. Subsequent requests include token in `auth-token` header
6. Middleware validates token before processing request
### SSL Certificate Security
- **Private keys** stored only in Redis (memory/disk based on config)
- **Fallback certificates** used when SNI unavailable
- **Let's Encrypt** rate limiting respected
- **DNS provider credentials** marked as `isPrivate` (not returned in API)
### Unix Socket Communication
- Socket file: `/var/run/proxy_lookup.socket`
- Permissions: `777` (container-safe, single-use deployment)
- Protocol: JSON over Unix stream socket
- Buffer handling: Accumulates partial messages until complete JSON
## Performance Optimizations
### Caching Strategy
The system uses a multi-tier caching approach:
1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request
- Primary host configuration storage
- Survives Node.js restarts/failures
- Shared across all OpenResty workers
2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching
- Only queried when Redis has no entry
- Rebuilt automatically when hosts change
- Supports complex wildcard resolution
3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis
- Subsequent requests to `api.example.com` hit Redis directly
- No repeated wildcard resolution needed
### Unix Socket vs HTTP API
Unix socket chosen over HTTP for host lookups:
- **Lower latency** - No TCP overhead
- **Higher throughput** - No HTTP parsing
- **Simpler** - Direct JSON communication
- **Secure** - Filesystem permissions, no network exposure
## Scalability Considerations
### Current Architecture
- **Single instance** - OpenResty + Node.js + Redis on one server
- **Vertical scaling** - Add CPU/RAM as needed
- **Limitations** - Unix socket ties OpenResty to Node.js on same host
### Future Scaling Options
- **Redis cluster** - Distribute data storage
- **Multiple OpenResty instances** - Load balance incoming requests
- **Stateless Node.js** - Run multiple API instances
- **Replace Unix socket** - Use TCP/HTTP for cross-host communication
- **Separate cert management** - Dedicated service for wildcard SSL
## Monitoring and Observability
### Logs
- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log`
- **Node.js**: `journalctl -u proxy.service`
- **Redis**: `redis-cli MONITOR`
### Health Checks
- Node.js API: `curl http://localhost:3000/api/host`
- Redis: `redis-cli PING`
- OpenResty: `systemctl status openresty`
- Unix socket: `ls -la /var/run/proxy_lookup.socket`
### Metrics to Monitor
- Request rate and response times
- SSL certificate expiration dates
- Redis memory usage
- Host lookup cache hit rate
- Background service execution times
[← Back to Home](index.html)
+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;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+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

+75
View File
@@ -0,0 +1,75 @@
---
layout: default
title: Users, Groups & Permissions
description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy.
---
# Users, Groups & Permissions
This page explains, in plain language, who can manage what in this app. For
the deeper system-design detail, see [Architecture](architecture.html).
## Two different ways to log in
Most people who use apps you've proxied through this app never see this
app's own login at all — they use whatever authentication you set up on
the *individual host* (basic auth, or single sign-on through your SSO
Manager). This page is about a different, smaller group: the people who
manage the proxy itself — adding hosts, registering DNS providers, and so
on.
There are two ways someone gets into the proxy's own management UI:
- **A local account**, created on the **Users** page — a username and
password specific to this app.
- **Single sign-on**, if you've connected this proxy to an SSO Manager (or
another OIDC provider) — the same login your other connected apps use.
Either way, once logged in, what they're actually *allowed to do* here is
controlled by permissions, described below.
## Groups
A **group** here is just a named list of local usernames, used to grant
the same permission to several people at once instead of one at a time.
If you're using SSO instead of local accounts, group membership normally
comes from your identity provider instead — local groups exist mainly for
the local-account case.
## Permissions: scope + role
Each **permission** entry grants one subject (a user or a group) one
**role**, at one **scope** — the two are independent choices:
**Scope** — *where* the role applies:
- **Domain** — only hosts under one specific domain (e.g. someone can
manage everything under `example.com`, but can't see or touch a
completely different domain you also proxy).
- **Global** — everywhere, across every domain this proxy manages.
**Role** — *what* they can do within that scope:
- **Viewer** — read-only. Can see hosts and their settings, but not
change anything.
- **Manager** — full control over hosts (create, edit, delete) within
that scope.
- **Admin** — same host control as Manager, **plus**, but *only when
granted at Global scope*, the ability to manage other people's
permissions, DNS providers, and local user accounts. An Admin role
granted at Domain scope instead of Global behaves exactly like Manager
for that one domain — it does not unlock those extra admin-only pages.
In practice: give someone **Manager** on just the domain(s) they're
responsible for to delegate day-to-day host management without handing
them the keys to everything. Reserve **Global Admin** for people who
should be able to change anything, anywhere, including who else has
access.
## Want more detail?
This page doesn't cover the exact permission-checking implementation or
how SSO group membership maps into this system internally — for that, see
[Architecture](architecture.html).
[← Back to Home](index.html)
+60
View File
@@ -0,0 +1,60 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in theta42/proxy.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](api.html).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that registers or updates hosts automatically (say, spinning up
a new service and wanting the proxy entry created for it without a
manual step).
- A monitoring or backup job that checks this app's health via its API.
- A configuration-management tool that keeps your host list in sync with
something else.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** [permissions](concepts-access.html)
— if you're only a Manager on one domain, a token you create can't touch
any other domain either. If you ever suspect a token has leaked (ended up
somewhere it shouldn't have, like a public script or log file), revoke it
immediately from your Profile page; it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](api.html).
[← Back to Home](index.html)
+49
View File
@@ -0,0 +1,49 @@
---
layout: default
title: DNS Providers
description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates.
---
# DNS Providers
This page explains, in plain language, what a "DNS provider" is for in this
app and when you actually need one. For setup steps, see
[Installation](installation.html).
## Do you need this at all?
**Only if you want a [wildcard host](concepts-hosts.html)** (something like
`*.example.com` covering every subdomain with one certificate). A normal,
single-name host doesn't need a DNS provider configured at all — skip this
page entirely if that's all you're setting up.
## Why a wildcard cert needs this extra step
To prove you actually own `example.com` before issuing a certificate that
covers *every* possible subdomain of it, Let's Encrypt needs to see a
specific, temporary DNS record appear on that domain — something only the
real owner of the domain could add. A normal single-host certificate
doesn't need this because it can prove ownership a simpler way (by
responding to a web request instead).
So: to get a wildcard certificate, this app needs to be able to add (and
later remove) that one temporary DNS record on your domain automatically,
which means it needs your domain registrar or DNS host's API credentials —
that's what registering a **DNS provider** here does.
## What you're actually giving it access to
A DNS provider entry only needs enough access to add/remove TXT records —
it's not given your registrar account's full login, and it can't do
anything to your domain besides that one narrow task (and, for some
providers, keeping a dynamic A record updated if you use that feature
separately). Check your specific provider's page in the
[Installation guide](installation.html) for exactly what kind of
credential to generate and how narrowly you can scope it.
## Want more detail?
For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun,
DuckDNS, etc.), see [Installation](installation.html).
[← Back to Home](index.html)
+81
View File
@@ -0,0 +1,81 @@
---
layout: default
title: Hosts & HTTPS
description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy.
---
# Hosts & HTTPS
This page explains, in plain language, what a "host" is and how this app
gets you working HTTPS without you having to think about certificates. For
the deeper system-design detail, see [Architecture](architecture.html); for
step-by-step setup, see [Installation](installation.html).
## What's a "host"?
A **host** is one entry telling the proxy: "when someone requests *this*
public address, send them to *that* server." For example: requests for
`photos.example.com` get sent to the little box in your closet running your
photo app on port 8080. Each app or service you want to reach from outside
your network — a home automation dashboard, a media server, this proxy's
own management UI — gets its own host entry.
Two settings on a host are easy to mix up:
- **Incoming host name** — the public address people type in their
browser (`photos.example.com`).
- **Target IP/port** — where the proxy actually sends the request behind
the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`).
Everything else on the host form (traffic limits, access rules,
authentication) is optional — a bare host with just those two fields
already works.
## HTTPS certificates: mostly automatic
Every public website needs an HTTPS certificate so browsers show the lock
icon instead of a scary warning. This app gets one for you automatically
from [Let's Encrypt](https://letsencrypt.org) the first time a host is
actually requested — you don't manually request, install, or renew
anything for a normal host. This happens behind the scenes using a method
called **HTTP-01**, and it's the default for every new host.
## Wildcards: one certificate for a whole family of hosts
Sometimes you want *every* subdomain under one name to work — `app1.`,
`app2.`, `anything.example.com` — without registering each one by hand and
waiting for its own certificate. That's what a **wildcard** host does: a
single host entry named `*.example.com` gets one certificate that covers
the whole family at once. Setting one up needs one extra piece of
information the automatic method above doesn't need — see
[DNS Providers](concepts-dns.html) for why.
Once a wildcard exists, you have two ways to actually use it:
- **Register nothing else, and turn on "Match any subdomain"** on the
wildcard host itself — *any* subdomain that doesn't already have its own
entry gets automatically routed to the wildcard's target the first time
it's requested. Convenient, but it means literal typos and random scan
traffic get routed too, not just the subdomains you meant to use.
- **Register each subdomain as its own host, as a "Parent Wildcard"
child** — more setup, but each subdomain can point at a different
target/server while still reusing the one wildcard certificate instead
of getting its own. This is the recommended default and is what
"Match only subdomains defined here" (the host form's default) does.
You'll see the **"Parent Wildcard"** option light up automatically on the
host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`).
## Load Balancing
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
## Want more detail?
This page skips the system-internals (Redis, OpenResty, the lookup service)
and the exact install steps. For those, see
[Architecture](architecture.html) and [Installation](installation.html).
[← Back to Home](index.html)
+344
View File
@@ -0,0 +1,344 @@
---
layout: default
title: Contributing
description: How to contribute to the proxy — dev setup, tests, and code conventions.
---
# Contributing Guide
[← Back to Home](index.html)
Thank you for considering contributing to the Proxy project! This guide will help you get started.
## Development Setup
### Prerequisites
- Node.js 18+ (18.x, 20.x, or 22.x recommended)
- Redis server
- Git
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
```
2. **Install dependencies**
```bash
npm install
```
3. **Start Redis** (if not already running)
```bash
redis-server
```
4. **Run in development mode**
```bash
npm run dev
```
This starts the Node.js API with nodemon for auto-reload on file changes.
5. **Access the API**
- API: `http://localhost:3000/api`
- Web UI: `http://localhost:3000`
## Testing
The project uses Node.js built-in test runner (requires Node 18+).
### Running Tests
```bash
# Run all tests
npm test
# Run only unit tests
npm run test:unit
# Run only integration tests
npm run test:integration
# Watch mode for development
npm run test:watch
```
### Test Structure
```
test/
├── unit/ # Unit tests for isolated components
│ ├── basicauth.test.js
│ ├── callback_queue.test.js
│ ├── dynamic_record.test.js
│ ├── host_features.test.js
│ ├── host_lookup.test.js
│ ├── hostname_validate.test.js
│ ├── host_sso.test.js
│ ├── oidc.test.js
│ ├── password_policy.test.js
│ ├── roles.test.js
│ ├── safe_redirect.test.js
│ ├── unix_socket.test.js
│ └── wildcard_matchany.test.js
├── integration/ # Integration tests
│ └── dns_provider.test.js
└── helpers/ # Test utilities
└── dns_provider_contract.js
```
### Writing Tests
We test **custom logic**, not third-party libraries:
**DO test:**
- Host lookup algorithm
- Socket buffering logic
- DNS provider contracts
- Custom utility functions
**DON'T test:**
- Express.js routing
- Redis ORM
- External DNS APIs (use mocks instead)
### Adding DNS Provider Tests
When adding a new DNS provider, you **must** add contract tests:
```javascript
describe('NewProvider Provider', () => {
const NewProvider = require('../../models/dns_provider/newprovider');
test('should meet DNS provider contract', () => {
const mockCredentials = {api_key: 'mock-key'};
const instance = validateDnsProviderContract(NewProvider, mockCredentials);
assert.ok(instance);
});
test('should have valid method signatures', () => {
const instance = new NewProvider({api_key: 'mock'});
validateMethodSignatures(instance);
});
test('should validate key mapping', () => {
const instance = new NewProvider({api_key: 'mock'});
validateKeyMapping(instance);
});
test('should validate type checking', () => {
const instance = new NewProvider({api_key: 'mock'});
validateTypeChecking(instance);
});
});
```
See `test/integration/dns_provider.test.js` for examples.
## Code Style
### General Guidelines
- Use strict mode: `'use strict';`
- Use tabs for indentation
- Clear, descriptive variable names
- Comment complex logic
- No trailing whitespace
### File Organization
```javascript
'use strict';
// 1. Node.js built-ins
const fs = require('fs');
const path = require('path');
// 2. Third-party modules
const express = require('express');
const redis = require('redis');
// 3. Local modules
const {Host} = require('./models');
const middleware = require('./middleware/auth');
// 4. Code...
```
### Naming Conventions
- Classes: `PascalCase`
- Functions: `camelCase`
- Constants: `UPPER_SNAKE_CASE`
- Private methods: `__privateMethod` (double underscore prefix)
## Project Structure
Understanding the codebase:
```
nodejs/
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models (Host, User, DNS providers)
├── routes/ # API route handlers
├── services/ # Background services (lookup, scheduler)
├── middleware/ # Express middleware
├── utils/ # Utility functions
├── public/ # Static web assets
├── views/ # EJS templates
└── test/ # Test suite
```
## Pull Request Process
### Before Submitting
1. **Run tests** - Ensure all tests pass
```bash
npm test
```
2. **Test locally** - Verify your changes work
```bash
npm run dev
```
3. **Update documentation** - Keep docs in sync with code changes
4. **Commit messages** - Use clear, descriptive messages
```
Add DNS provider for Route53
- Implement Route53 DNS API client
- Add contract tests for Route53
- Update documentation with Route53 setup
```
### Submitting a PR
1. **Fork the repository**
2. **Create a feature branch**
```bash
git checkout -b feature/my-new-feature
```
3. **Make your changes**
4. **Commit your changes**
```bash
git add .
git commit -m "Description of changes"
```
5. **Push to your fork**
```bash
git push origin feature/my-new-feature
```
6. **Open a Pull Request** on GitHub
### PR Requirements
- All tests must pass (CI/CD runs automatically)
- Tests run on Node.js 18.x, 20.x, and 22.x
- No merge conflicts with `master`
- Code follows project conventions
- New features include tests
- Documentation updated if needed
### CI/CD Process
When you open a PR:
1. GitHub Actions automatically runs tests
2. Tests execute on multiple Node.js versions
3. PR cannot be merged until all checks pass
4. Review from maintainers
5. Merge to master
## Data Models
The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`.
**Example Model:**
```javascript
const Table = require('../utils/redis_model');
class Host extends Table {
static _key = 'host'; // Primary key field
static _keyMap = {
'host': {isRequired: true, type: 'string', min: 3, max: 500},
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
'targetPort': {isRequired: true, type: 'number', min: 0, max: 65535},
'forcessl': {default: true, type: 'boolean'},
'created_on': {default: () => Date.now(), type: 'number'}
};
}
```
**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis)
## Adding Features
### Adding a DNS Provider
1. **Create provider file** in `models/dns_provider/yourprovider.js`
2. **Extend DnsApi base class**
```javascript
const {DnsApi} = require('./common');
class YourProvider extends DnsApi {
static _keyMap = {
api_key: {isRequired: true, type: 'string', isPrivate: true}
};
// Implement required methods
async listDomains() { }
async getRecords(domain, options) { }
async createRecord(domain, options) { }
async deleteRecords(domain, options) { }
}
```
3. **Add to provider list** in `models/dns_provider.js`
4. **Add contract tests** in `test/integration/dns_provider.test.js`
5. **Test your provider**
```bash
npm run test:integration
```
### Adding API Endpoints
1. **Add route** in appropriate file (`routes/`)
2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync)
3. **Test the endpoint** manually and add integration tests if needed
## Getting Help
- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions)
- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues)
- **Security issues** Email maintainers directly (see package.json)
## Code of Conduct
- Be respectful and inclusive
- Focus on constructive feedback
- Help others learn and grow
- Follow the project's technical direction
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
---
[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy)
Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

+78
View File
@@ -0,0 +1,78 @@
---
layout: default
title: Home
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
---
# Proxy
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
management API and web GUI. It puts any of your apps behind single sign-on
(OIDC) and can also look users up directly in LDAP — so the same people who
log in to your SSO are the people allowed to reach your proxied apps.
Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
and per-host access control tied to your identity provider — managed from a
web UI or a REST API, with no downtime on config changes.
Part of the theta42 self-hosted identity stack, alongside
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
command).
## Screenshots
<a href="images/hosts.png" target="_blank"><img src="images/hosts.png" alt="Host list" width="49%"></a>
<a href="images/host-auth-sso.png" target="_blank"><img src="images/host-auth-sso.png" alt="Per-host SSO auth" width="49%"></a>
Basic auth and SSO are mutually exclusive per host, with per-user password
management once basic auth is enabled:
<a href="images/host-auth-basic.png" target="_blank"><img src="images/host-auth-basic.png" alt="Per-host basic auth" width="60%"></a>
*(click any screenshot to view full size)*
## Why this over the alternatives
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
auto-HTTPS. This one is built around identity: it is both an **OIDC client**
of an SSO provider (for browser login) **and** a direct **LDAP client** (for
user lookups and per-host access control), so access decisions come from your
real user directory, not a static allow-list or a separate auth proxy bolted
on top. The trade-off is that it expects an OIDC/LDAP identity source to point
at — it is not an auth server on its own. Pair it with
[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
LDAP directory you already run.
## Features
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
- Dynamic host routing with wildcard domain matching (`*`, `**`)
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
- **OIDC login** and **direct LDAP lookups**, independently of each other
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
it's never ambiguous which one gated a request)
- **Role-based access control** — global admins, local groups, and
per-domain permissions (viewer/manager)
- Self-service API tokens for scripting/CI without a browser session
- Web UI and a full REST API
## Get it
```bash
git clone https://github.com/theta42/proxy.git
cd proxy && docker compose up -d --build
```
For the full set of install options (Docker, bare-metal, or as part of the combined
SSO + proxy stack), configuration reference, and API docs, see the
**[GitHub repository](https://github.com/theta42/proxy)**.
## Related projects
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
provider + LDAP directory this proxy is designed to sit in front of.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
SSO Manager together with one command.
+17 -12
View File
@@ -1,7 +1,7 @@
---
layout: default
title: Quickstart
description: Step-by-step first run for theta-env — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
description: Step-by-step first run for theta-suite — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
---
# Quickstart Guide
@@ -12,8 +12,7 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
## Prerequisites
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
compose` or the v1 standalone `docker-compose` both work).
- A Linux host with **Docker + Docker Compose** (you must use the modern `docker compose` v2 plugin; the older `docker-compose` v1 standalone will fail on BuildKit images).
- Two hostnames that resolve to the host: one for the SSO UI (your `stack.ssoHost`),
one for the proxy mgmt UI (your `stack.proxyHost`). On a real network add DNS
records; for a local try, add them to `/etc/hosts`.
@@ -26,8 +25,8 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
## 1. Clone
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
```
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
@@ -60,8 +59,7 @@ setups `CFG_DOMAIN` is the only value you set:
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
| `CFG_JUMP_HOST_ENABLED` | `true` | optional: bring up the [SSH jump host](https://theta42.github.io/jump-host/) (default off) |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` (the [SSH jump host](https://theta42.github.io/jump-host/) is installed + started by default) |
| `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) |
`setup.env` is used **only on the first run** to generate `./config/`; after
@@ -142,11 +140,19 @@ 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).
> **Troubleshooting: "A newer version is available" after running setup.sh?**
> If the UI shows this warning immediately after you ran `./setup.sh`, the latest
> GitHub release tag might not yet be merged into the default tracking branch for
> the submodules, or Docker may have cached the `COPY` step if the `package.json`
> didn't change. You can force a clean rebuild by running
> `docker compose build --no-cache` and then re-running `./setup.sh`.
---
## Direct LDAP for legacy apps
## Direct LDAP for LDAP-native clients and Linux hosts
Legacy apps bind LDAP directly over LDAPS:
LDAP-native apps and Linux hosts (PAM/SSSD, sudo, SSH keys) bind LDAP directly
over LDAPS:
```bash
ldapsearch -x -H ldaps://<host>:636 \
@@ -165,7 +171,7 @@ or the admin DN. Use LDAPS (636), not plain LDAP.
before each rebuild (keeps the last `BACKUP_KEEP`, default 5). For manual
backups and the full restore runbook (full / Redis-only / LDAP-only, with the
AOF-vs-RDB note), see the *Backups and restore* section of the
[README](https://github.com/theta42/theta-env#backups-and-restore). Quick LDAP
[README](https://github.com/theta42/theta-suite#backups-and-restore). Quick LDAP
backup:
```bash
@@ -183,7 +189,6 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
under **API Tokens** in each UI, mint a personal access token and use it as
`Authorization: Bearer sso_…` (SSO) or `prx_…` (proxy). A token authenticates as
its creator with their permissions. See each submodule's DEPLOYMENT.
- See [Architecture](architecture.html) for how it all fits together, and
[Standalone](standalone.html) to run either project on its own.
- See [Architecture](architecture.html) for how it all fits together.
[← Back to Home](index.html)
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
Sitemap: https://theta42.github.io/theta-suite/sitemap.xml
+247
View File
@@ -0,0 +1,247 @@
---
layout: default
title: Secrets (OpenBao)
description: theta-suite's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies.
---
# Secrets — OpenBao as the central store
theta-suite keeps **every secret in one place: [OpenBao](https://openbao.org/)**
(a Vault-community fork), running on the `theta-net` docker network at
`http://openbao:8200`. The three apps (SSO Manager, proxy, jump host) load
their boot secrets from it; end users get personal per-user secret storage
through the SSO UI; and external apps get scoped, self-contained access to
their own namespace.
This page is the operator reference. For the package API, see
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/).
## Why a central store
Before this, secret handling was partial and inconsistent: only the SSO read
one path from OpenBao; the proxy and jump host read bind-mounted
`./config/*-secrets.js` files; the bootstrap wrote generated OAuth creds to
those files on disk; and the SSO `/api/vault` UI was an ungated, broken
pass-through. Centralising on OpenBao gives every app the same fail-soft load
path, makes per-user secret storage possible, and lets external apps get
least-privilege access without anyone handing them the root token.
## The load path (every app)
1. `@simpleworkjs/conf` **synchronously** loads the bind-mounted
`./config/<app>-secrets.js` at require time — the file is the operator-edit
layer and the fail-soft fallback.
2. `@simpleworkjs/bao-conf`'s `init({ path: '<app>', conf })` **deep-merges**
`secret/data/<app>/conf` from OpenBao over the live `conf` object. It is
**fail-soft**: if OpenBao is unreachable or the path is absent, boot
continues with the file-loaded config.
3. A few secrets are **captured at require time** (notably the OIDC
`clientSecret`, consumed inside `createOidcClient` during
`require('../models')`). So `init()` must resolve *before* that
`require()`. Each app's `bin/www` handles this:
- **proxy** — defers `require('../app')` (which transitively loads models)
behind `bao-conf.init()`.
- **jump host** — gates the explicit `require('../models')` behind
`bao-conf.init()`.
- **SSO** — swaps the old `conf_manager.init()` call (same position in its
existing `.then()` boot chain) for `bao-conf.init()`; nothing in the SSO
captures a secret at require time, so no reordering was needed.
`VAULT_TOKEN` (a scoped per-app token, **not** the root token) and
`VAULT_ADDR=http://openbao:8200` are passed to each container via
`docker-compose.yml`. The `./config/*-secrets.js` mounts stay as the fallback.
## Policies, token role, and tokens
`setup.sh` creates the ACL policies and mints the per-app tokens
(idempotently — re-running keeps existing tokens and re-mints only expired
ones). The root token stays in `.env` for setup/maintenance **only** and is
never passed to a service container.
| Policy | Capabilities | Held by |
|---|---|---|
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
| `user-<uid>` | read/write `secret/users/<uid>/*` | per-user tokens (minted lazily by the broker) |
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
**Token roles** — three, all orphan + renewable:
- `sso-broker``allowed_policies=sso-admin`, `allowed_policies_glob=user-*,app-*`,
`token_period=24h`. The SSO mints per-user and per-admin tokens *through*
this role at runtime, so it never needs the root token to issue scoped
access. The 24h period is fine here because the broker re-mints these from
its Redis cache transparently.
- `sso-app``allowed_policies_glob=app-*`, `token_period=768h`. External-app
tokens minted from the vault UI's Apps tab go through this role: they are
long-lived credentials, so they get a monthly period instead of a daily one.
- `theta-svc``allowed_policies=sso-broker,proxy,jump-host`,
`token_period=768h`. The services' own tokens (below).
### Token lifecycle — nothing expires by surprise
Periodic tokens never hit a max TTL, but they die if nothing renews them
inside a period window. Renewal is automated at every layer:
- **Service tokens** (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`, minted via `theta-svc`, stored in `./.env`): the
`bao-renewer` sidecar (docker-compose) renews all three every 12 hours, and
every `setup.sh` re-run renews them too. A valid-but-non-periodic token from
an older install is detected, revoked, and re-minted as periodic on the next
`setup.sh` run.
- **External-app tokens** (minted in the SSO vault UI): the SSO stores each
token's **accessor** (which can renew/revoke but not authenticate) and
renews it every 6 hours and at boot — a downstream app's credential stays
valid as long as the SSO is running, with no renewal code in the downstream
app. Re-minting an app's token revokes the previous one via its accessor, so
exactly one credential per app is ever live.
- **Per-user / admin tokens**: 24h TTL by design; the broker re-mints them
transparently, so there is nothing to renew.
Worst case (the whole stack was down for >32 days): re-run `./setup.sh` — it
re-mints anything that lapsed; external-app tokens are re-minted from the
Apps tab (the app's policy and stored secrets are kept).
## Seeding
`setup.sh` seeds, on first run only (skipped if the path already exists):
- `secret/sso-manager/conf` — from `./config/sso-secrets.js` (operator-set
LDAP/SMTP/`jwtSecret`; the SSO has no bootstrap-generated creds, so the file
is the complete source of truth).
- `secret/proxy/conf` — from `./config/proxy-secrets.js` (placeholder OAuth
creds at this point).
- `secret/jump-host/conf` — from `./config/jump-secrets.js` after the bootstrap
writes it.
The **bootstrap** (`bootstrap/bootstrap.js`) then generates the real OAuth
client credentials and writes the *complete* `proxy-secrets.js` and
`jump-secrets.js` objects into `secret/proxy/conf` and `secret/jump-host/conf`
(POST, replacing the placeholder seed). After the first run, OpenBao is
authoritative; the `./config/*-secrets.js` files are operator-edit seed
artifacts and the fail-soft fallback.
## End-user personal secrets
Every logged-in user has a personal namespace `secret/users/<uid>/*`, reached
through the SSO UI at **Vault → My Secrets**. The SSO mints a `user-<uid>`
token on first access (cached in Redis for the token's lifetime) and proxies
`/api/vault` to OpenBao with **that** token injected server-side — the
client's SSO session token never reaches OpenBao.
- **Non-admins** see only their own namespace; the UI fixes the path prefix
to `users/<uid>/`. They can list, read, write, and delete secrets there.
- **Admins** (`app_sso_admin` / `app_super_admin`) get free-form access across
all of `secret/` plus an **Apps** tab (see below).
Scoping is enforced at **two** layers: the SSO's `scopeGuard` rejects any path
outside the subject's prefix with a 403 (defense-in-depth), and the token's
own OpenBao policy enforces the same at the API layer.
## External apps
An external (non-theta42) app gets scoped access to its own namespace,
`secret/apps/<name>/*`, via a token an admin mints once from the SSO UI's
**Vault → Apps** tab. The token is shown **once** (copy it immediately; it is
not stored retrievably) and confined by an `app-<name>` policy.
Convention:
- `secret/apps/<name>/conf` for config-style secrets, `secret/apps/<name>/*`
for arbitrary keys.
- The app authenticates with the header `X-Vault-Token: <minted token>`
against `http://<openbao-host>:8200/v1/secret/data/apps/<name>/...`.
Non-Node consumers (curl):
```bash
VAULT_ADDR=http://openbao:8200 # or your external-facing openbao address
# Write
curl -X POST "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" -H "Content-Type: application/json" \
-d '{"data":{"db_password":"..."}}'
# Read
curl -s "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" | jq .data.data
```
Node consumers can use [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
directly:
```js
const baoConf = require('@simpleworkjs/bao-conf');
const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-service/conf
await baoConf.set('apps/my-service/conf', { db_password: '...' });
```
## Plugin secrets
The SSO Manager's plugin system (configurable plugin instances you create,
edit, load/unload, and run from the **Plugins** page) stores each instance's
secrets in its own OpenBao namespace, `secret/plugins/<instance-id>/conf`,
rather than in the static `sso-secrets.js` `discovery.plugins` block. The
SSO reads and writes these server-side through the `sso-broker` token (the
plugin runs in-process as a BullMQ worker, so it needs no token of its own),
and the admin UI only ever sees masked (`********`) values.
- A **plugin type** is a module under `nodejs/plugins/<category>/<type>.js`
exporting a manifest (`configSchema` declares which fields are `secret`).
- A **plugin instance** is a configured, loadable/unloadable copy of a type,
tracked in the `PluginInstance` table; you can have multiple instances of the
same type (e.g. two Proxmox endpoints with their own tokens).
- Non-secret config lives in the DB row; only the `secret:true` field values
live in `secret/plugins/<instance-id>/conf`.
Deleting an instance removes both the DB row and its `secret/plugins/<id>/*`
namespace. Legacy `discovery.plugins` entries in `sso-secrets.js` are migrated
to instances automatically on the first boot of SSO Manager ≥ v1.17.0 (the
secret fields are copied into OpenBao at that point). See the SSO Manager
[plugins docs](https://theta42.github.io/sso-manager-node/plugins.html) for the
UI/API reference.
## Operator rotation
If a secret is exposed (or just on a routine schedule), rotate it at the
**provider** first (the LDAP server, the SMTP host, the OAuth `jwtSecret`,
etc.), then update OpenBao:
```bash
# Read the current sso-manager conf
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf
# Write a new value (KV-v2 POST replaces the data; merge carefully)
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf \
ldap.bindPassword='<new>' smtp.password='<new>' oauth.jwtSecret='<new>'
```
Then restart the affected app so `bao-conf.init()` re-reads it
(`docker compose restart sso-manager`). Call-time readers pick up the change
on next read; require-time captures (OIDC `clientSecret`) need the restart.
> The SSO admin **Configuration** UI (`/api/conf`) writes `secret/sso-manager/conf`
> and updates the live conf immediately, so SMTP/discovery/oauth edits made
> there don't need a manual `bao kv put`.
## Backups
The OpenBao data volume `openbao-data` holds every secret. Back it up with the
rest of the stack (see the README's *Backups and restore* section). The
`./config/*-secrets.js` files are **not** a complete secret backup once OpenBao
is authoritative — they're the first-run seed and the fallback. A full disaster
recovery restores both the `openbao-data` volume (the authoritative store) and
`./config/` (the seed/fallback), then runs `./setup.sh` to unseal OpenBao and
re-mint the per-app tokens.
## What's not in scope yet
- **Renewal automation** — per-app/user tokens use OpenBao's default TTL and
are re-minted by `setup.sh` on expiry; a periodic renewal worker is a
follow-up.
- **History scrubbing** — if a secret was committed to git, rotating it is the
fix; scrubbing it from git history (BFG / `git filter-repo`) is a separate,
git-destructive operation you can opt into.
- **Per-app secrets beyond boot config** (e.g. the proxy's DNS-provider creds,
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
boot-critical `*-secrets.js` contents moved in this phase. (Plugin instance
secrets *are* in OpenBao, at `secret/plugins/<id>/conf` — see above.)
+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>
+88
View File
@@ -0,0 +1,88 @@
---
layout: default
title: Discovery Agents
nav_order: 5
---
# Discovery Agents
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
## Writing a Custom Agent
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
### Agent Skeleton
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
// 1. Fetch your data from an API
// const data = await fetch(...);
// 2. Map data to Resources
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
return { resources, edges };
}
};
```
## Configuration
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
```
## The Reconciliation Engine
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+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

+125
View File
@@ -0,0 +1,125 @@
---
layout: default
title: Accounts, Groups & Managers
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
---
# Accounts, Groups & Managers
This page explains the concepts behind the Users and Groups pages in plain
language. If you want the technical schema/attribute-level detail instead,
see the [LDAP reference](ldap.html).
## What's an account?
Every person (or app) that can sign in through this SSO Manager has an
**account** — a username, a display name, maybe an email address, and a
password (or, for service accounts, no password at all — see below).
Accounts live in the directory this app manages, and any other app you've
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
same accounts instead of keeping its own separate list of users and
passwords.
## Two kinds of account: people and service accounts
Most accounts belong to an actual person — check **Users → People** to see
them. But sometimes you need an account for something that *isn't* a
person: a media server, a backup script, a bind account another app uses to
look people up. These are **service accounts**, listed separately under
**Users → Service Accounts**, and they're different from a person's account
in two ways that matter:
- **No email required.** A service account doesn't need a mailbox, so the
form doesn't ask for one.
- **A password is optional.** If you leave it blank, nobody can log in as
that account — which is exactly what you want for something that only
ever gets used programmatically (a script authenticating with an API
token, or another app binding with a fixed, separately-configured
password you set yourself). Only give it a password if the account
genuinely needs to log in or bind somewhere as itself.
Aside from those two differences, a service account is a completely normal
account under the hood — it can belong to groups, have a manager, and so
on, just like anyone else's.
## Groups: who can do what
A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the
**Groups** page, and point the other app's "who's allowed in" setting at
that group's name.
### Groups inside groups
A group can contain another group, not just people — the *Nested* tab on any
group card. Everyone in the inner group counts as a member of the outer one,
however many levels deep it goes.
This is mostly a way to stop repeating yourself. Make one `developers` group,
nest it into the handful of things developers should reach, and adding a new
developer to that one group grants all of them at once — instead of adding them
to each individually and slowly drifting out of sync. The app already does this
for itself: super admins are nested into every resource's admin group, and each
admin group into its access group, so "can administer it" always implies "can
use it".
Two things it won't let you do: put a group inside itself (directly or round a
longer loop), and empty a group completely — every group must keep at least one
member.
A note if you also manage the directory by hand: a group's member list shows
what is *directly* listed on it. Someone who gets in through a nested group is
a real member but won't appear there — the **Nested** tab shows what is nested,
and the API's `effective` view lists everyone who actually gets in.
## Every account's personal group
Separately from the groups above, every single account — person or
service account — automatically gets its own small, personal group when
it's created, named after the account itself. Most of the time you'll
never think about this; it exists so that, on a Linux system connected to
this directory, each account "owns" its own files by default the same way
a normal Unix user account would.
Occasionally you'll want to share that ownership with someone else — for
example, letting a second account also have write access to files a
service account owns. That's what the **"Members of `<uid>`'s group"**
section on a profile page is for: add another account there, and the
underlying Linux permissions treat them as if they belong to that same
personal group too.
## What's a "manager"?
Every account has one or more **managers** — the people allowed to edit
that account's profile (phone number, SSH key, home directory, and so on)
without needing full admin rights. By default, whoever created an account
(the admin who added it, or whoever sent the invite) becomes its first
manager, but you can add or remove managers later from the account's Edit
form.
This is useful for service accounts especially: if a service account
belongs to a particular project or person, make them its manager so they
can maintain it — rotate its SSH key, adjust its description — without
needing to be a full SSO administrator.
## Inviting someone vs. adding them yourself
From the Users page you can either fill in someone's details yourself
("Add new user"), or send them an **invite** — an email (or a link you copy
and send however you like) that lets them pick their own username and
password. Either way, the resulting account is identical; invites are just
a convenience so you don't have to know someone's preferred username or
handle their password directly.
## Want more detail?
This page deliberately leaves out LDAP schema names, attribute types, and
protocol-level detail. If you're connecting a third-party app directly to
the LDAP directory, or you just want to know exactly what's stored where,
see the [LDAP reference](ldap.html).
[← Back to Home](index.html)
+59
View File
@@ -0,0 +1,59 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in SSO Manager.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that syncs users or groups from somewhere else into this SSO
Manager on a schedule.
- A backup or monitoring job that checks this app's health via its API.
- A CI/CD pipeline that needs to register or update an OAuth client
automatically.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** permissions — if you're not an
admin, a token you create can't do admin-only things either. If you ever
suspect a token has leaked (ended up somewhere it shouldn't have, like a
public script or log file), revoke it immediately from your Profile page;
it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
[← Back to Home](index.html)
+79
View File
@@ -0,0 +1,79 @@
---
layout: default
title: Connecting Apps (Single Sign-On)
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
---
# Connecting Apps (Single Sign-On)
This page explains, in plain language, what happens when you "connect" an
app to your SSO Manager so people can log into it with their existing
account. For the technical endpoint/token detail, see the
[OAuth reference](oauth.html).
## What does "single sign-on" actually mean?
Instead of every app you run having its own separate list of usernames and
passwords, they all check with this SSO Manager instead. You log in once,
here, and any connected app trusts that login — no separate password to
remember or manage for each one. If you ever need to lock someone out
everywhere at once, you do it in one place (deactivate their account here)
instead of hunting down every app individually.
The technology behind this is called **OAuth 2.0** and **OpenID Connect
(OIDC)** — you'll see both names used, often together, referring to the
same thing. You don't need to understand the protocol to use this page;
what matters practically is the handful of concepts below.
## What's a "client"?
Every app you connect is registered here as a **client** — a single entry
in the Directory representing that one app. Registering a client
gives you a **Client ID** and **Client Secret**: think of these like a
username and password, but for the *app itself* rather than for a person.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
screen, along with the discovery URL shown at the top of this page, and
that app is now able to ask this SSO Manager to authenticate people on its
behalf.
**Treat the Client Secret like a password** — anyone who has it can
impersonate that app when talking to your SSO Manager. If you ever suspect
it's leaked, rotate it from the client's card.
## What are "scopes"?
**Scopes** control what information a connected app is allowed to ask for
about the person logging in — their username, email, group memberships,
and so on. Most apps tell you exactly which scopes they need in their own
setup instructions; when in doubt, the default set (`openid`, `profile`,
`email`, `groups`) covers what nearly every app expects.
## "Restrict to Groups"
By default, *any* account with an SSO Manager login can sign into a
connected app. If that's not what you want — say, a home automation
dashboard that only certain family members should reach — set **Restrict
to Groups** on that client to one of your [groups](concepts-accounts.html).
Only members of that group will be allowed to log into that particular
app; everyone else gets turned away at the login step, even though their
SSO Manager account still works everywhere else.
## Redirect URIs
A **Redirect URI** is the exact web address the connected app wants people
sent back to once they've logged in here — it's a security measure so an
attacker can't trick the login flow into redirecting somewhere else. The
app's own setup instructions will tell you this value; copy it in exactly
as given. If the app is reachable via more than one hostname (for example,
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
this field supports wildcard patterns — see the inline help under the
field itself for the exact syntax.
## Want more detail?
This page intentionally skips the protocol-level detail (exact endpoint
URLs, token formats, claim names). If you're troubleshooting a connection
or building something against the API directly, see the
[OAuth reference](oauth.html).
[← Back to Home](index.html)
+104
View File
@@ -0,0 +1,104 @@
---
layout: default
title: Configuration
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
---
# Configuration
[← Back to Home](index.html)
The app loads configuration via
[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which
deep-merges, in order (later wins):
1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`,
`localhost`, `SSO Manager`).
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
4. **`app_*` environment variables** — the highest-precedence layer.
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name splits on **double-underscore** (`__`) into a nested path. Values are
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
raw strings otherwise.
## Examples
| Env var | Sets | Type |
|---------|------|------|
| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string |
| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string |
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
| `app_smtp__secure=false` | `conf.smtp.secure` | boolean |
| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string |
| `app_name=My SSO` | `conf.name` | string |
| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) |
## The `app_*` env layer requires conf >= 1.1.0
The `app_*` environment-variable override layer was added in
`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only
reads `base.js` / `<NODE_ENV>.js` / `secrets.js`. The Docker image will not honor
`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory:
```bash
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
```
## Inspecting the merged config
From the `nodejs/` directory:
```bash
node -e "console.log(require('@simpleworkjs/conf').ldap)"
node -e "console.log(require('@simpleworkjs/conf').oauth)"
node -e "console.log(require('@simpleworkjs/conf'))" # everything
```
Or, inside the running container:
```bash
docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)"
```
`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value
isn't what you expect, check those layers in that order.
## Migrating an existing instance to the generic defaults
The committed `nodejs/conf/base.js` ships **generic** defaults
(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth
issuer). If you run an existing instance off this repo:
- Move per-deployment, non-secret values (bind DN, user/group bases, SMTP
host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored
`conf/secrets.js`, **or** set them as `app_*` env vars.
- Secret values (LDAP bind password, SMTP password, JWT secret) already belong
in `secrets.js`.
## Troubleshooting `app_*` env vars
### `app_*` vars seem to do nothing
You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above).
### LDAP operations 401 / "Invalid Credentials"
Check the merged LDAP config the app actually sees:
```bash
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
```
Confirm `url` / `bindDN` / `bindPassword` / `userBase` match your directory.
[← Back to Home](index.html)
+139
View File
@@ -0,0 +1,139 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
## Architecture
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
## Automatic LDAP Group Creation
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
1. `<slug>_access` (Member level access)
2. `<slug>_admin` (Owner level access)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
### Who sees which metadata
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
- **Never returned**`client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
## Catalog & access requests
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
- **Discover More** — everything else in the directory, with a **Request access** button.
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
- `POST /api/access-requests``{slug | resourceId, groupCn?, note?}`
- `GET /api/access-requests/mine` — the caller's own history
- `GET /api/access-requests` — pending requests the caller may decide
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

+85
View File
@@ -0,0 +1,85 @@
---
layout: default
title: Home
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
---
# SSO Manager
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
and a web management UI — for home labs and small businesses that want their
own identity provider instead of a hosted one.
One place to manage your users and groups, one login (OIDC) your modern apps
can use, and one LDAP directory your older or odder apps can bind to directly.
Everything runs on your own hardware; no phone-home, no hosted control plane,
no per-user pricing.
Part of the theta42 self-hosted identity stack, alongside
[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy)
and [theta-env](https://theta42.github.io/theta-env/) (the two composed with
one command).
## Screenshots
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
*(click any screenshot to view full size)*
## Why this over the alternatives
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
LDAP is either a paid feature, a federation target you have to run
separately, or absent. If your stack already has apps that speak LDAP
directly — or you just want one real directory as the source of truth — you
end up running *two* identity systems and keeping them in sync.
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
apps and LDAP apps read from the same users and groups. The trade-off is
scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
If you want a lightweight, self-contained identity provider with a real LDAP
backend, that's the niche.
## Features
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
tokens; standard discovery document at `/.well-known/openid-configuration`.
- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
keys, and sudo roles, with `memberOf` + referential-integrity overlays.
- **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API
tokens.
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
## Get it
```bash
git clone https://github.com/theta42/sso-manager-node.git
cd sso-manager-node
cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
docker compose up -d --build
```
For the full set of install options
(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
`app_*` env reference, and the OAuth/LDAP internals, see the
**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
## Related projects
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
reverse proxy, designed to sit in front of this SSO.
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
uses this SSO's directory to decide who may reach which machine.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
Manager and the proxy together with one command.
+432
View File
@@ -0,0 +1,432 @@
---
layout: default
title: LDAP
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
---
# LDAP Directory
[← Back to Home](index.html)
> Looking for a plainer explanation of accounts, groups, and managers
> instead of schema/attribute detail? See
> [Accounts, Groups & Managers](concepts-accounts.html).
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
theta42/proxy, etc.
## Directory layout
```
dc=yourdomain,dc=com
├── ou=people users (inetOrgPerson + posixAccount + …)
├── ou=groups groups (groupOfNames)
└── ou=policies password policies (pwdPolicy)
└── cn=ppolicy default policy
```
### Users
User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs.
- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's
`userFilter` is `(objectClass=posixAccount)`, so a user is "a real account"
iff it has `posixAccount`.
- `ldapPublicKey` — SSH public keys (`sshPublicKey`).
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
- `theta42Person` (custom auxiliary; `dateOfBirth`).
Every user (person or service account) also carries a `manager` attribute
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
the people who created/administer that account. Set automatically to the
creator's DN on signup (whoever an admin was logged in as, or whoever sent
the invite), and reassignable later 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,
and the manager list itself) without needing `app_sso_admin`.
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
canonical hasher; if you provision users out-of-band, hash passwords the same
way or use `slappasswd -h '{SSHA512}'`.
### Groups
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
attribute listing member DNs. The `memberOf` overlay populates reverse
membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove.
Note that `groupOfNames` requires **at least one member**, which has two
consequences worth knowing: whoever creates a group is automatically seeded
into it, and removing the last member (user *or* nested group) is refused with
a 409 rather than leaving an invalid entry behind.
### Nested groups
A `member` DN may be another group's, not just a user's — that is how nesting
is stored, with no extra schema. Everyone in the nested group is a member of
the outer one, at any depth. Manage it on the **Groups** page under each
group's *Nested* tab, or via the API:
```
PUT /api/group/:group/nested/:child nest :child inside :group
DELETE /api/group/:group/nested/:child un-nest
GET /api/group/:group/effective direct users, nested groups, and the
full transitive set of users
```
Cycles are refused (409) rather than truncated — a loop makes "who is in this
group" unanswerable. Two standing relationships are wired automatically: the
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
something implies being able to use it.
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
direct membership only. The bundled slapd is therefore built from source with
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
closure itself — same answers, more queries. Either way, **never read `memberOf`
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
which is correct in both modes.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO seeds these groups automatically (entrypoint / `install.sh`):
| Group | Grants |
|-------|--------|
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
| `app_sso_admin` | full admin (users, groups, settings) |
| `app_sso_oauth_admin` | OAuth client management |
| `app_sso_invite` | invitation management |
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
## TLS (LDAPS / StartTLS)
The bundled slapd generates a **self-signed cert** on first start (CN =
`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on:
- `ldaps:///`**636**, TLS (the port to expose for direct-LDAP clients).
- `ldap:///`**389**, plain + StartTLS (not mapped to the host by default).
The cert lives on the `ldap-certs` volume so it persists across container
recreation.
### Trusting the self-signed cert
Copy it out and add it to the client's CA store:
```bash
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
```
…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy
sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect).
### Using your own cert
Replace the `ldap-certs` named volume with a bind mount containing your own
`ldap.crt` + `ldap.key`:
```yaml
volumes:
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
```
The entrypoint leaves existing certs untouched (idempotent).
## Choosing the LDAPS hostname
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
default it derives that URL from the public OAuth issuer (e.g.
`https://sso.example.com``ldaps://sso.example.com:636`). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set `conf.ldap.ldapsHost` (or
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
### 1. Same Docker / local network host (best for apps on this machine)
If the LDAP client runs on the same Docker network as the SSO Manager (for
example, the bundled `theta-env` stack), use the internal service name:
```
ldaps://sso-manager:636
```
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
```
The proxy in theta-env already uses this internally. The bundled slapd cert
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
so hostname verification works without extra setup.
### 2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. `ldap.internal.example.com`
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
or generate a cert whose SAN/CN matches that internal name:
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
public domain and can complete DNS-01 challenge; the record itself can stay
private/routable only inside your LAN.
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
`ldap.internal.example.com`, and distribute the CA cert to clients.
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
the generated `ldap.crt` to each client and trust it.
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'ldap.internal.example.com',
ldapsPort: 636,
}
```
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
### 3. Public hostname (acceptable only behind a VPN/firewall)
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
etc.) or a tightly locked-down firewall rule. In that case the public hostname
may be appropriate, but the LDAPS port should still not be reachable from the
open internet.
### Why not just use the LDAP server's IP address?
TLS clients verify the server name against the certificate. Connecting to
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
will fail hostname verification unless you disable cert checks — which removes
most of the security benefit of LDAPS. Always use a hostname that matches the
cert.
## Service accounts
A service account is a normal `posixAccount` for something that isn't a
person: a media manager, a torrent client, a service like Emby, or a
read-only bind account an app uses to look users up — anything that needs a
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
group for write access (e.g. a `stuff_manager` group granting write rights
to a media library). There's only one kind — every account, person or
service, is a real `posixAccount` with a UID.
Create one from the **Users → Service Accounts** tab's "Add new user" form
with **This is a service account** checked — it skips the birthday/
Terms-of-Service fields a real person's account needs and asks for just an
account name. It's flagged (via membership in the `app_sso_service_account`
group) so it's listed separately from real people and excluded from "all
users" notification broadcasts.
Email and password are both optional for a service account:
- No `mail` is set unless you give it one (it never needs a mailbox).
- Leaving the password blank is fine — no `userPassword` attribute is set at
all, and an entry with no `userPassword` simply can't bind with any
password (standard LDAP simple-bind behavior). Only set a password if the
account actually needs to authenticate as itself (e.g. a bind-only account
an app uses to look users up).
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
against LDAP (independent of this app), and the proxy binds as it — that
account won't show up in the Service Accounts tab since it isn't managed
through this app, but it keeps working unchanged.
Either way: don't reuse the admin DN, and give a service account only the
group memberships and `manager`s it actually needs.
Example bind test (a service account with a password set):
```bash
ldapsearch -x -H ldaps://sso.example.com:636 \
-D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \
-b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail
```
## Connecting a 3rd-party app or container
Most self-hosted apps with an "LDAP authentication" settings page — Gitea,
Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via
`LDAP_*` env vars, all ask for the same handful of values. These are the
`conf.ldap` values from [Configuration](configuration.html), applied to
*your* domain:
| Field the app asks for | Value |
|---|---|
| Host / URL | `ldaps://<your-sso-host>:636` (preferred), or `ldap://<host>:389` + StartTLS |
| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,<base>` (see above) |
| Bind password | that service account's password |
| User search base | `ou=people,<base>` |
| User search filter | `(objectClass=posixAccount)` |
| Username attribute | `uid` |
| Email attribute | `mail` |
| Group search base | `ou=groups,<base>` |
| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) |
| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use |
### Worked example: Gitea
Gitea's **Admin → Authentication Sources → Add Authentication Source** (type
LDAP, "Bind DN/Password") maps directly:
- Security Protocol: `LDAPS`
- Host / Port: your SSO host / `636`
- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com`
- Bind Password: the service account's password
- User Search Base: `ou=people,dc=yourdomain,dc=com`
- User Filter: `(&(objectClass=posixAccount)(uid=%s))`
- Username Attribute: `uid`
- E-mail Attribute: `mail`
Other apps with an LDAP settings UI follow the same shape — the field names
above are the constants; only the base DN and hostname change per deployment.
### Generic Docker container (`LDAP_*` env vars)
For images that take a flat env-var LDAP config (there's no single standard,
but most look like this):
```yaml
environment:
LDAP_URL: ldaps://sso.example.com:636
LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
LDAP_BIND_PASSWORD: <service-account-password>
LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com
LDAP_USER_FILTER: (objectClass=posixAccount)
LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com
```
Check the specific image's docs for its actual variable names — the values
you plug in are still the ones from the table above.
### Full Linux host auth (SSH, sudo, login) instead of a single app
If you want a *host* (not just one app) to authenticate logins, SSH keys, and
sudo against this LDAP directory — not just one application — that's a
different integration (SSSD + PAM + NSS, not a single bind). See
[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that
configures SSSD on Ubuntu/Debian hosts against this directory, including
group-based access control and SSH public key retrieval from LDAP.
## Modules + overlays (external LDAP servers)
If you point the app at your own LDAP server instead of the bundled slapd, it
needs:
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
`ppolicy`, `memberof`, `refint`.
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
unreleased), so the bundled image builds slapd from a pinned upstream commit.
Without it the app resolves nesting itself and everything still works — leave
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
configure:
```
overlay nestgroup
nestgroup-base ou=groups,<base>
nestgroup-flags member-filter memberof-filter memberof-values
```
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
notation suggests is rejected. `member-values` is deliberately omitted — it
expands the `member` attribute when reading a group, which destroys the
distinction between "listed here" and "reachable through a nested group", and
the raw values are then unrecoverable. Transitive answers come from the filter
flags and from `GET /api/group/:group/effective`.
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
is a `slapcat``slapadd` reload, not a restart.
- **Custom schema:** the `theta42Person` auxiliary objectClass with
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
also nests it into the first three).
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
idempotently against a running slapd (auto-detects the database holding your
base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's
active/inactive toggle depends on).
## Backups and restore
`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention)
— see the *Backups and restore* section of
`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the
hood, useful if you want just the directory without Redis/config.
**Backup** (while slapd is running):
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
```
Store the `.ldif` off the host — it contains every user's password hash.
**Restore** into a stopped directory. The SSO image uses a static `slapd.conf`
(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`:
```bash
docker compose stop sso-manager
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
< ldap-backup-<date>.ldif
docker compose start sso-manager
```
Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
Redis state (OAuth clients, tokens) and `./config/` secrets are backed up
separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the
full (LDAP + Redis + secrets) runbook.
## Troubleshooting
### `503 OpenLDAP ppolicy overlay is not configured`
The ppolicy overlay isn't attached to the database holding your users, so the
active/inactive toggle can't set `pwdAccountLockedTime`:
```bash
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
```
### LDAP connection refused
```bash
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd # bare metal
```
[← Back to Home](index.html)
+111
View File
@@ -0,0 +1,111 @@
---
layout: default
title: OAuth / OIDC
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
---
# OAuth 2.0 / OpenID Connect
[← Back to Home](index.html)
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
> of endpoint-level detail? See
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
access, refresh, and ID tokens that your apps can consume to authenticate
users and authorize API calls. It also runs a full OpenLDAP directory, so it
can be both your SSO and your user directory at once.
## Discovery
The provider publishes a standards-compliant discovery document:
```
GET https://<sso-host>/.well-known/openid-configuration
```
It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`,
`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token
lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs
from here rather than configuring each one.
The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing**
HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in
`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`.
## OAuth clients
An OAuth client represents an app that authenticates against the SSO. Each has:
- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is
shown once** when the client is created or rotated — save it immediately).
- `name`, `description`, `created_by` (the admin uid that created it).
- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may
use `*` (one hostname label) / `**` (any number of labels) as a wildcard —
e.g. `https://*.example.com/__proxy_auth/callback` covers every host
theta42/proxy fronts under `example.com`, so you don't have to register
each proxied host's callback individually.
- `scopes` — requested scopes (default `openid profile email groups`).
- `allowed_groups` — restrict the client to members of specific SSO groups
(empty = any valid user).
- `token_lifetime``access_token` / `refresh_token` lifetimes (seconds).
### Managing clients
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
| Action | How to do it |
|--------|--------------|
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes
| Scope | Claims / access |
|-------|-----------------|
| `openid` | OIDC ID token + discovery |
| `profile` | `preferred_username`, display name, etc. |
| `email` | the user's `mail` |
| `groups` | the user's group memberships (the `groups` claim) |
The `groups` claim is what relying parties (e.g. the proxy's
`app_auth__adminGroups`) use to map group membership to roles.
## Token lifetimes
Defaults (overridable per-client via `token_lifetime`, or globally via
`app_oauth__token_lifetime__access_token` /
`app_oauth__token_lifetime__refresh_token`):
- access token: 3600s (1 hour)
- refresh token: 2592000s (30 days)
## Admin gating
SSO admin actions are gated by LDAP group membership (checked via the group's
`member` list, not `memberOf` on the user):
- `app_sso_admin` — full admin (users, groups, settings).
- `app_sso_oauth_admin` — OAuth client management.
- `app_sso_invite` — invitation management.
The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your
first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin`
automatically.
## JWT signing
Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` /
`JWT_SECRET`). **Persist this secret** — if it changes, every issued token
stops validating. The all-in-one Docker image auto-generates one if none is set,
but that generated value does not survive container recreation unless you
persist it (set `JWT_SECRET` in your `.env`).
[← Back to Home](index.html)
+55
View File
@@ -0,0 +1,55 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
## Why and when to use this?
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
### Example using `theta-env` / Docker Compose
**Site 1 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
## User Locations
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
+45
View File
@@ -0,0 +1,45 @@
---
layout: default
title: Secrets Vault
nav_order: 6
---
# Secrets Vault
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
## Architecture
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
## Accessing the Vault
The SSO Manager Vault can be accessed in two ways:
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
### API Example
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## Namespaces and Paths
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
## Plugin Integration
Plugin instances store their per-instance secrets in OpenBao at
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
in-process, so the SSO Manager reads/writes those secrets server-side through
the `sso-broker` token; the admin UI only ever sees masked values, and external
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
consistently enforced instead of hardcoding them.
-136
View File
@@ -1,136 +0,0 @@
---
layout: default
title: Standalone
description: Running the SSO Manager or the proxy on their own, without theta-env's orchestration.
---
# Running each project standalone
[← Back to Home](index.html)
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:
```bash
git clone https://github.com/theta42/sso-manager-node.git
cd sso-manager-node
mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build
```
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 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.2.0. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal
```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -n 'Your Org' -o 3001
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:
```bash
git clone https://github.com/theta42/proxy.git
cd proxy
mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build
```
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 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/`
- Health: `http://127.0.0.1:3000/health`
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
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
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
documented in both projects' deployment guides:
1. One Docker network (or reachable hostnames) so the proxy can reach the SSO
internally for token/userinfo + LDAPS.
2. Set the SSO's `oauth.issuer` (in its `secrets.js`) to the browser-facing HTTPS
URL the proxy serves the SSO at.
3. Register the proxy as an OIDC client in the SSO, with `redirectUri` matching
the proxy's callback; put the resulting `clientId`/`clientSecret` in the
proxy's `secrets.js`.
4. Point the proxy's `ldap.url` at the SSO's LDAPS + create a dedicated
`cn=ldapclient` service account; set the same password as `bindPassword`.
theta-env just automates those four steps with `./setup.sh`. If you prefer to
do them by hand (or want the two on separate hosts), follow the standalone
guides above.
[← Back to Home](index.html)
Submodule
+1
Submodule ldap-client added at 68fcdb53bd
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env bash
#
# LDAP Migration Script for theta42
#
# Migrates an existing OpenLDAP server to the theta42 stack.
# Exports data from source, transforms as needed, imports into theta42.
#
# Usage:
# ./migrate-ldap.sh --source-host <ldap-uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
#
# Example:
# ./migrate-ldap.sh --source-host ldap://192.168.1.10:389 --source-bind-dn "cn=admin,dc=example,dc=com" --source-bind-pass "secret" --target-domain "example.com"
#
set -euo pipefail
cd "$(dirname "$0")"
# ── Defaults ──────────────────────────────────────────────────────────────────
SOURCE_HOST=""
SOURCE_BIND_DN=""
SOURCE_BIND_PASS=""
TARGET_DOMAIN=""
BASE_DN=""
EXPORT_DIR="./ldap-migration-$(date +%Y%m%d-%H%M%S)"
THETA_ENV_DIR="$(cd "$(dirname "$0")" && pwd)"
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() { printf "${BLUE}[migrate]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[migrate]${NC} %s\n" "$*" >&2; }
error() { printf "${RED}[migrate]${NC} %s\n" "$*" >&2; }
success() { printf "${GREEN}[migrate]${NC} %s\n" "$*" >&2; }
die() { error "$*"; exit 1; }
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--source-host)
SOURCE_HOST="$2"
shift 2
;;
--source-bind-dn)
SOURCE_BIND_DN="$2"
shift 2
;;
--source-bind-pass)
SOURCE_BIND_PASS="$2"
shift 2
;;
--target-domain)
TARGET_DOMAIN="$2"
shift 2
;;
--export-dir)
EXPORT_DIR="$2"
shift 2
;;
--help|-h)
cat <<EOF
LDAP Migration Script for theta42
Usage: $0 --source-host <uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
Options:
--source-host Source LDAP URI (e.g., ldap://192.168.1.10:389 or ldaps://ldap.example.com:636)
--source-bind-dn Bind DN for source LDAP (e.g., cn=admin,dc=example,dc=com)
--source-bind-pass Bind password for source LDAP
--target-domain Target domain for theta42 (e.g., example.com)
--export-dir Directory for exports (default: ./ldap-migration-<timestamp>)
--help Show this help message
EOF
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
# ── Validation ────────────────────────────────────────────────────────────────
[[ -n "$SOURCE_HOST" ]] || die "Missing --source-host"
[[ -n "$SOURCE_BIND_DN" ]] || die "Missing --source-bind-dn"
[[ -n "$SOURCE_BIND_PASS" ]] || die "Missing --source-bind-pass"
[[ -n "$TARGET_DOMAIN" ]] || die "Missing --target-domain"
# Derive base DN from domain (e.g., example.com -> dc=example,dc=com)
BASE_DN="$(echo "$TARGET_DOMAIN" | sed 's/\./,dc=/g; s/^/dc=/')"
info "Migration configuration:"
info " Source host: $SOURCE_HOST"
info " Source bind DN: $SOURCE_BIND_DN"
info " Target domain: $TARGET_DOMAIN"
info " Target base DN: $BASE_DN"
info " Export dir: $EXPORT_DIR"
# ── Prerequisites ─────────────────────────────────────────────────────────────
command -v ldapsearch >/dev/null 2>&1 || die "ldapsearch not found. Install ldap-utils."
command -v slapcat >/dev/null 2>&1 || die "slapcat not found."
command -v docker >/dev/null 2>&1 || die "docker not found."
command -v docker-compose >/dev/null 2>&1 || command -v docker compose >/dev/null 2>&1 || die "docker compose not found."
if [[ -d "$EXPORT_DIR" ]]; then
warn "Export directory already exists: $EXPORT_DIR"
read -p "Overwrite? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Aborted."
exit 1
fi
fi
mkdir -p "$EXPORT_DIR"
# ── Phase 1: Export from source LDAP ─────────────────────────────────────────
info "Phase 1: Exporting data from source LDAP..."
# Export each subtree
export_subtree() {
local base="$1"
local outfile="$2"
info " Exporting $base -> $outfile"
# Use ldapsearch with -LLL for LDIF output
if ! ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
-b "$base" -s sub "(objectClass=*)" > "$outfile" 2>/dev/null; then
warn " No data or base DN not found: $base"
# Create empty file to signal "checked"
echo "# No data for $base" > "$outfile"
fi
}
# Export standard subtrees
export_subtree "ou=people,$BASE_DN" "$EXPORT_DIR/01-people.ldif"
export_subtree "ou=groups,$BASE_DN" "$EXPORT_DIR/02-groups.ldif"
export_subtree "ou=sudoers,$BASE_DN" "$EXPORT_DIR/03-sudoers.ldif"
export_subtree "ou=services,$BASE_DN" "$EXPORT_DIR/04-services.ldif"
# Also export cn=config for reference (read-only, won't import)
info " Exporting cn=config for reference..."
ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
-b "cn=config" -s sub "(objectClass=*)" > "$EXPORT_DIR/00-config-reference.ldif" 2>/dev/null || true
# Count entries
for f in "$EXPORT_DIR"/*.ldif; do
count=$(grep -c "^dn:" "$f" 2>/dev/null || echo 0)
info " $(basename "$f"): $count entries"
done
success "Export complete: $EXPORT_DIR"
# ── Phase 2: Transform LDIF ──────────────────────────────────────────────────
info "Phase 2: Transforming LDIF for theta42 compatibility..."
# Create transformation script
cat > "$EXPORT_DIR/transform.sh" <<'TRANSFORM_SCRIPT'
#!/usr/bin/env bash
# Transform exported LDIF for theta42 compatibility
INPUT="$1"
OUTPUT="$2"
BASE_DN="$3"
# theta42 requires certain objectClasses and attributes
# This script:
# 1. Ensures posixAccount has uidNumber, gidNumber, homeDirectory, loginShell
# 2. Ensures groupOfNames has at least one member
# 3. Adds ldapPublicKey objectClass where sshPublicKey exists
# 4. Normalizes password hash formats if needed
while IFS= read -r line || [[ -n "$line" ]]; do
echo "$line"
done < "$INPUT" > "$OUTPUT"
echo "Transform complete: $OUTPUT"
TRANSFORM_SCRIPT
chmod +x "$EXPORT_DIR/transform.sh"
# For now, we'll do a direct import. The transformation is minimal for most setups.
# If you have custom schemas, you may need to edit the LDIF manually.
# ── Phase 3: Prepare theta42 LDAP ────────────────────────────────────────────
info "Phase 3: Preparing theta42 LDAP..."
# Stop theta42 stack
COMPOSE_CMD=""
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
die "docker compose not found"
fi
info " Stopping sso-manager container..."
$COMPOSE_CMD stop sso-manager 2>/dev/null || true
# Wait for container to stop
sleep 3
# ── Phase 4: Import into theta42 ─────────────────────────────────────────────
info "Phase 4: Importing data into theta42 LDAP..."
# Create import script that runs inside the container
cat > "$EXPORT_DIR/import-to-theta42.sh" <<'IMPORT_SCRIPT'
#!/bin/bash
# Run inside theta42 sso-manager container to import LDIF
set -e
EXPORT_DIR="$1"
BASE_DN="$2"
# Stop slapd if running
pkill slapd 2>/dev/null || true
sleep 2
# Clear existing data (but preserve structure)
info "Clearing existing LDAP data..."
rm -rf /var/lib/ldap/*
rm -rf /var/lib/ldap/db.*
# Initialize LDAP database with theta42 schema
info "Initializing LDAP database..."
# Create initial LDIF with base structure
cat > /tmp/base.ldif <<EOF
dn: $BASE_DN
objectClass: top
objectClass: dcObject
objectClass: organization
dc: $(echo $BASE_DN | sed 's/,dc=.*//; s/dc=//')
o: Organization
dn: ou=people,$BASE_DN
objectClass: organizationalUnit
ou: people
dn: ou=groups,$BASE_DN
objectClass: organizationalUnit
ou: groups
dn: ou=sudoers,$BASE_DN
objectClass: organizationalUnit
ou: sudoers
dn: ou=services,$BASE_DN
objectClass: organizationalUnit
ou: services
dn: cn=admin,$BASE_DN
objectClass: organizationalRole
cn: admin
description: LDAP Administrator
dn: cn=ldap-admin,ou=groups,$BASE_DN
objectClass: groupOfNames
cn: ldap-admin
member: cn=admin,$BASE_DN
EOF
# Import base structure
slapadd -c -l /tmp/base.ldif -b "$BASE_DN" 2>/dev/null || true
# Import user data
for f in "$EXPORT_DIR"/*.ldif; do
[[ -f "$f" ]] || continue
[[ "$(basename "$f")" == "00-config-reference.ldif" ]] && continue
info "Importing $f..."
# Use -c to continue on errors (some entries may already exist)
slapadd -c -l "$f" -b "$BASE_DN" 2>/dev/null || warn "Some entries in $f may have failed"
done
# Fix ownership
chown -R ldap:ldap /var/lib/ldap
# Start slapd
info "Starting slapd..."
exec /usr/sbin/slapd -h "ldap:/// ldaps:///" -u ldap -g ldap
IMPORT_SCRIPT
# Copy import script to export dir
cp "$EXPORT_DIR/import-to-theta42.sh" "$EXPORT_DIR/"
# Run the import inside the container
info "Running import inside sso-manager container..."
# First, start a temporary container to do the import
$COMPOSE_CMD up -d sso-manager 2>/dev/null || true
sleep 5
# Copy LDIF files into container
info "Copying LDIF files to container..."
for f in "$EXPORT_DIR"/*.ldif; do
[[ -f "$f" ]] || continue
docker cp "$f" sso-manager:/tmp/migration/ 2>/dev/null || {
docker exec sso-manager mkdir -p /tmp/migration
docker cp "$f" sso-manager:/tmp/migration/
}
done
# Run import
info "Executing import..."
docker exec sso-manager bash -c "
pkill slapd 2>/dev/null || true
sleep 2
# Clear data
rm -rf /var/lib/ldap/*
# Create base structure
slapadd -c -b '$BASE_DN' <<EOF
dn: $BASE_DN
objectClass: top
objectClass: dcObject
objectClass: organization
dc: $(echo $BASE_DN | cut -d',' -f1 | cut -d'=' -f2)
o: $TARGET_DOMAIN
dn: ou=people,$BASE_DN
objectClass: organizationalUnit
ou: people
dn: ou=groups,$BASE_DN
objectClass: organizationalUnit
ou: groups
dn: ou=sudoers,$BASE_DN
objectClass: organizationalUnit
ou: sudoers
dn: ou=services,$BASE_DN
objectClass: organizationalUnit
ou: services
EOF
# Import user data
for f in /tmp/migration/*.ldif; do
[[ \"\$(basename \$f)\" == \"00-config-reference.ldif\" ]] && continue
[[ -f \"\$f\" ]] || continue
echo \"Importing \$f...\"
slapadd -c -l \"\$f\" -b '$BASE_DN' 2>/dev/null || echo \"Warning: Some entries in \$f may have failed\"
done
# Fix ownership
chown -R ldap:ldap /var/lib/ldap
echo \"Import complete!\"
" || warn "Import had some errors - check output above"
# ── Phase 5: Create theta42 admin groups ─────────────────────────────────────
info "Phase 5: Creating theta42 admin groups..."
# Create LDIF for theta42-specific groups
cat > "$EXPORT_DIR/theta42-groups.ldif" <<EOF
# theta42 administrative groups
# These groups control access to various features
# Cross-app super admin - full admin in all apps
dn: cn=app_super_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_super_admin
description: Cross-app super administrators
# SSO Manager admin
dn: cn=app_sso_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_admin
description: SSO Manager administrators
# SSO invite - can invite users
dn: cn=app_sso_invite,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_invite
description: Can send invitations
# OAuth admin - manages OAuth clients
dn: cn=app_sso_oauth_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_oauth_admin
description: OAuth client administrators
# Service account marker
dn: cn=app_sso_service_account,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_service_account
description: Service accounts (hidden from UI)
# Jump host admin - audit access only
dn: cn=app_jump_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_jump_admin
description: Jump host audit administrators
EOF
# Import the theta42 groups
docker exec sso-manager bash -c "
slapadd -c -l /tmp/theta42-groups.ldif -b '$BASE_DN' 2>/dev/null || echo \"Groups may already exist\"
" <<EOF
$(cat "$EXPORT_DIR/theta42-groups.ldif")
EOF
# ── Phase 6: Restart and verify ──────────────────────────────────────────────
info "Phase 6: Restarting theta42 stack..."
$COMPOSE_CMD restart sso-manager
sleep 10
info "Waiting for sso-manager to be healthy..."
for i in $(seq 1 30); do
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
success "sso-manager is healthy!"
break
fi
if (( i == 30 )); then
warn "sso-manager did not become healthy in 30s. Check logs with: docker compose logs sso-manager"
fi
sleep 2
done
# Verify import
info "Verifying import..."
dn_count=$(docker exec sso-manager ldapsearch -x -H "ldap://localhost" -b "$BASE_DN" -s sub "(objectClass=*)" dn 2>/dev/null | grep -c "^dn:" || echo 0)
info "Total entries in LDAP: $dn_count"
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
success "Migration complete!"
echo ""
info "Summary:"
info " - Exported data saved to: $EXPORT_DIR"
info " - Base DN: $BASE_DN"
info " - Total entries: $dn_count"
echo ""
info "Next steps:"
info " 1. Review the exported LDIF files in $EXPORT_DIR"
info " 2. Add users to theta42 admin groups as needed:"
info " docker exec sso-manager ldapmodify -x -H ldap://localhost -D 'cn=admin,$BASE_DN' -w <admin-pass>"
info " 3. Update your LDAP clients to point to theta42"
info " 4. Run ./setup.sh to complete theta42 bootstrap"
echo ""
warn "IMPORTANT: Update all LDAP clients to use the new theta42 LDAP server!"
warn " - SSSD: Update /etc/sssd/sssd.conf ldap_uri"
warn " - sudo: Update /etc/sudo-ldap.conf"
warn " - Apps: Update LDAP connection strings"
+1 -1
View File
@@ -1 +1 @@
https://github.com/theta42/theta-env/pull/75
https://github.com/theta42/theta-suite/pull/75
+1 -1
Submodule proxy updated: 58204da3cb...bbaa006925
+30 -18
View File
@@ -1,5 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# setup.env — first-run setup for the theta-env stack.
# setup.env — first-run setup for the theta-suite stack.
#
# This file is used ONLY on the FIRST run of ./setup.sh, to generate
# ./config/sso-secrets.js + ./config/proxy-secrets.js with your domain filled
@@ -33,15 +33,14 @@ CFG_DOMAIN=example.com
#CFG_SSO_HOST=sso.example.com
#CFG_PROXY_HOST=proxy.example.com
# ── Optional SSH jump host ───────────────────────────────────────────────────
# Enable the theta42/jump-host component: a public SSH jump host that
# authenticates users against the directory and bridges them to downstream
# hosts (ssh uid_-_target@jump, or an interactive picker). Off by default.
# When true, setup.sh clones/builds the jump-host submodule, the bootstrap
# mints its directory API token + writes ./config/jump-secrets.js, and it's
# registered in the proxy + directory. See jump-host's README for the LDAP
# write-ACL note (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST_ENABLED=false
# ── SSH jump host (always installed) ─────────────────────────────────────────
# The theta42/jump-host component is installed and started by default — a
# public SSH jump host that authenticates users against the directory and
# bridges them to downstream hosts (ssh uid_-_target@jump, or an interactive
# picker). setup.sh clones/builds the jump-host submodule, the bootstrap mints
# its directory API token + writes ./config/jump-secrets.js, and it's registered
# in the proxy + directory. See jump-host's README for the LDAP write-ACL note
# (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST=jump.example.com # defaults to jump.<domain>
#JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default)
@@ -74,13 +73,6 @@ CFG_DOMAIN=example.com
# '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
#CFG_SMTP_PORT=587
#CFG_SMTP_USER=noreply@example.com
#CFG_SMTP_PASS=your-smtp-password
#CFG_SMTP_FROM=SSO Manager <noreply@example.com>
# ── DO NOT put secrets here ──────────────────────────────────────────────────
# The LDAP admin password, JWT secret, admin password, LDAP service-account
# password, and the proxy's local admin password are all GENERATED (random)
@@ -91,6 +83,20 @@ CFG_DOMAIN=example.com
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
# CFG_PROXY_ADMIN_PASS here.
# ── theta-agent Host Integration ─────────────────────────────────────────────
# Configure theta-agent integration with the local host. All options default to
# enabled (1). Set to 0 to disable.
#
# Enable theta-agent installation and configuration on this host.
#CFG_THETA_AGENT_ENABLE=1
#
# Configure LDAP authentication for this host via ldap-client (SSSD/PAM).
#CFG_THETA_AGENT_LDAP_AUTH=1
#
# Allow theta-agent full control of this host (arbitrary_bash, service_control,
# reboot, configure_ldap capabilities).
#CFG_THETA_AGENT_FULL_CONTROL=1
# ── 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.
@@ -101,4 +107,10 @@ CFG_DOMAIN=example.com
# 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"
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
# If you are running the stack behind an external reverse proxy (like Cloudflare
# or another ingress) that handles TLS termination, you may want the internal
# proxy to serve everything over plain HTTP without forcing redirects to HTTPS.
# Set this to 1 to create all default proxy host entries with forcessl=false.
#CFG_CREATE_ALL_HTTP=1
+590 -56
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
#
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
# theta-suite setup — one-command bring-up of the unified SSO Manager + Proxy stack.
#
# git clone --recursive <theta-env> && cd theta-env
# git clone --recursive <theta-suite> && cd theta-suite
# 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)
@@ -19,7 +19,7 @@
# 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
# 0. Pull theta-suite'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
@@ -61,6 +61,7 @@ set -euo pipefail
cd "$(dirname "$0")"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
CONFIG_DIR=./config
BACKUP_DIR=./backups
BACKUP_KEEP="${BACKUP_KEEP:-5}"
@@ -71,6 +72,19 @@ warn() { printf '\033[1;33m[setup]\033[0m %s\n' "$*" >&2; }
error() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; }
die() { error "$*"; exit 1; }
# ── Flags ──────────────────────────────────────────────────────────────────────
# --reset-openbao: wipe the OpenBao volume + bao-init.json and re-initialize a
# fresh store (no prod data to preserve). Use when OpenBao state is suspect
# (stale policies/tokens causing vault 403s). The Redis vault-token cache is
# flushed once sso-manager is back up (see the OpenBao bootstrap section).
RESET_OPENBAO=0
for arg in "$@"; do
case "$arg" in
--reset-openbao) RESET_OPENBAO=1 ;;
*) warn "unknown argument: $arg (ignored)" ;;
esac
done
# Escape a value for a single-quoted JS string: \ -> \\, ' -> \', then wrap in '...'.
js_str() {
local s="$1"
@@ -88,6 +102,30 @@ rand_hex() {
fi
}
# Upsert KEY=VALUE into ./.env, which `docker compose` auto-loads for every
# future invocation in this directory. Used to persist the *_GIT_COMMIT build
# args (see SSO_GIT_COMMIT/PROXY_GIT_COMMIT/JUMP_GIT_COMMIT below) so that an
# ad-hoc `docker compose up --build <service>` run later, OUTSIDE this script,
# still resolves the right commit instead of silently baking "unknown" (the
# submodule .git pointer file can't be resolved from inside the build
# context, so the value must come from the host via this file or the export).
env_upsert() {
local key="$1" val="$2" file=./.env
touch "$file"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
else
printf '%s=%s\n' "$key" "$val" >> "$file"
fi
}
# Read KEY= from ./.env (empty if absent)
env_get() {
local key="$1" file=./.env
[[ -f "$file" ]] || return 0
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true
}
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
@@ -124,7 +162,7 @@ parse_kv_file() {
done < "$file"
}
# ── 0. Self-update: pull theta-env itself, then restart with the new version ──
# ── 0. Self-update: pull theta-suite 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
@@ -134,7 +172,7 @@ parse_kv_file() {
# 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" ]] \
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_SUITE_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
@@ -144,26 +182,24 @@ 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" "$@"
info "Updated theta-suite (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
THETA_SUITE_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."
warn "Could not fast-forward theta-suite 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.
# ── Jump host hostname (always installed) ─────────────────────────────────────
# The SSH jump host is a core component — always built + started (no longer
# gated by CFG_JUMP_HOST_ENABLED). Read its optional hostname override from
# ./setup.env now (before the submodule loop and the compose steps) so the
# later steps can use it. The authoritative CFG_* for secrets are still
# resolved in ensure_config; this is only the hostname override.
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
JUMP_ENABLED=0
case "${CFG_JUMP_HOST_ENABLED:-}" in 1|true|TRUE|yes|YES) JUMP_ENABLED=1 ;; esac
export CFG_JUMP_HOST_ENABLED CFG_JUMP_HOST
# When enabled, activate the compose profile so `up`/`ps` include the service.
if [[ "$JUMP_ENABLED" == "1" ]]; then export COMPOSE_PROFILES="jump-host"; fi
export CFG_JUMP_HOST
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
export CFG_CREATE_ALL_HTTP
# ── Optional outbound HTTP(S) proxy for docker build + the running containers ─
# CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the
@@ -196,9 +232,8 @@ if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
fi
# jump-host is optional: only track/build it when enabled.
SUBMODULES=(sso-manager-node proxy)
[[ "$JUMP_ENABLED" == "1" ]] && SUBMODULES+=(jump-host)
# jump-host is a core component — always tracked + built.
SUBMODULES=(sso-manager-node proxy jump-host)
info "Updating submodules to their latest release tag (${SUBMODULES[*]})..."
for sm in "${SUBMODULES[@]}"; do
[[ -d "$sm" ]] || continue
@@ -257,6 +292,21 @@ dn_from_domain() {
echo "dc=$1" | sed 's/\./,dc=/g'
}
# Read a value from the (operator-owned) ./config/sso-secrets.js -- the source of
# truth on re-runs, where the CFG_* first-run shell vars are not (re)derived
# (ensure_config returns early once sso-secrets.js exists). Reads `stack.<key>`.
# Prints empty on any failure. Usage: sso_secrets_get ldapBaseDn
sso_secrets_get() {
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c.stack&&c.stack[k]!=null?c.stack[k]:"")' \
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
}
# Read a top-level (non-stack) secret from sso-secrets.js, e.g. serviceAccountPass.
sso_secrets_get_top() {
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c[k]!=null?c[k]:"")' \
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
}
# Write ./config/sso-secrets.js from the CFG_* shell vars.
write_sso_secrets() {
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
@@ -292,6 +342,16 @@ module.exports = {
jwtSecret: $(js_str "$CFG_JWT_SECRET"),
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
},
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
// relative to the app's /app cwd -- inside the container's ephemeral layer,
// not any mounted volume -- so every Resource/site/host/service/oauth row
// (the whole Directory Management page) would be silently wiped on every
// container recreate. /data is already a persisted volume (Redis lives
// there too), so this just co-locates the sqlite file with it.
orm: {
dialect: 'sqlite',
storage: '/data/inventory.sqlite',
},
// ── Orchestrator-only (ignored by the app) ───────────────────────────────
stack: {
@@ -365,6 +425,23 @@ PROXYEOF
}
ensure_config() {
if [[ ! -f "$CONFIG_DIR/openbao.hcl" ]]; then
info "Generating $CONFIG_DIR/openbao.hcl ..."
mkdir -p "$CONFIG_DIR"
cat > "$CONFIG_DIR/openbao.hcl" <<BAOEOF
storage "file" {
path = "/vault/data"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
disable_mlock = true
ui = true
BAOEOF
chmod 644 "$CONFIG_DIR/openbao.hcl"
fi
if [[ -f "$CONFIG_DIR/sso-secrets.js" ]]; then
info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)."
return 0
@@ -403,6 +480,7 @@ ensure_config() {
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
# ── One-time migration from .env / proxy.env (existing deployments) ──
# Preserve the operator's existing secrets so the running deployment keeps
@@ -474,6 +552,7 @@ ensure_config() {
mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
write_sso_secrets
write_proxy_secrets
chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js"
if [[ "$migrated" == "1" ]]; then
@@ -525,7 +604,7 @@ backup_before_rebuild() {
# bind-mount was added), then fall back to reading it inside the container.
# Use `docker exec <name>` (not `docker-compose exec`) so the snapshot works
# no matter which compose project brought the container up — the unified
# theta-env stack (project "theta-env") and the standalone submodule stack
# theta-suite stack (project "theta-suite") and the standalone submodule stack
# (project "sso-manager-node") both name it "sso-manager". `docker-compose
# exec` from the superproject otherwise exits 1 silently (wrong project) and
# the snapshot silently no-ops.
@@ -577,7 +656,7 @@ backup_before_rebuild() {
docker exec "$svc" redis-cli BGSAVE >/dev/null 2>&1 || true
ok=0
for i in $(seq 1 10); do
if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9')" -gt "$before" ]]; then
if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)" -gt "$before" ]]; then
ok=1; break
fi
sleep 1
@@ -589,8 +668,8 @@ backup_before_rebuild() {
fi
# Redis writes dump.rdb to `dir`/`dbfilename`; ask it where that is so the
# copy works across the unified (/data) and standalone (/app) layouts.
rdir="$(docker exec "$svc" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
rfile="$(docker exec "$svc" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n')"
rdir="$(docker exec "$svc" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
rfile="$(docker exec "$svc" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
rpath="${rdir:+$rdir/}${rfile:-dump.rdb}"
if [[ "$ok" == "1" ]] && docker cp "$svc:$rpath" "$dir/$svc.rdb" >/dev/null 2>&1; then
info " Redis ($svc) -> $svc.rdb"
@@ -619,6 +698,283 @@ backup_before_rebuild() {
}
backup_before_rebuild
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
# Full reset (--reset-openbao): stop/remove openbao, drop the data volume, and
# delete the init/keys file (incl. any backup copy that setup.sh would otherwise
# restore). The normal bootstrap below then initializes a brand-new store, so no
# stale policy content or token survives.
if [[ "$RESET_OPENBAO" == "1" ]]; then
info "── Full OpenBao reset requested (--reset-openbao) ──"
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
docker volume ls -q 2>/dev/null | grep '^openbao' | xargs -r docker volume rm >/dev/null 2>&1 || true
rm -f "$CONFIG_DIR/bao-init.json"
rm -f ./backups/bao-init.json ./backups/*/bao-init.json 2>/dev/null || true
info " openbao volume + bao-init.json cleared; will re-initialize fresh."
fi
info "Starting openbao..."
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
"${COMPOSE[@]}" up -d openbao
info "Waiting for openbao to be reachable..."
for i in $(seq 1 30); do
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then
info "openbao is reachable."; break
fi
if (( i == 30 )); then die "openbao did not become reachable in 60s. Check: ${COMPOSE[*]} logs openbao"; fi
sleep 2
done
# If config/bao-init.json is missing, search backups for a saved copy
if [[ ! -f "$CONFIG_DIR/bao-init.json" ]]; then
latest_backup_init=$(find ./backups -name "bao-init.json" 2>/dev/null | sort -r | head -n1 || true)
if [[ -n "$latest_backup_init" && -f "$latest_backup_init" ]]; then
info "Restoring $CONFIG_DIR/bao-init.json from backup ($latest_backup_init)..."
cp "$latest_backup_init" "$CONFIG_DIR/bao-init.json"
chmod 600 "$CONFIG_DIR/bao-init.json"
fi
fi
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true'; then
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if ! echo "$status_json" | grep -q '"initialized": true'; then
info "Initializing openbao for the first time..."
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
chmod 600 "$CONFIG_DIR/bao-init.json"
info "Openbao initialized. Keys saved to $CONFIG_DIR/bao-init.json"
fi
fi
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if echo "$status_json" | grep -q '"sealed": true'; then
UNSEAL_KEY=""
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | tail -n1 | cut -d'"' -f2 || true)
fi
if [[ -z "$UNSEAL_KEY" ]]; then
UNSEAL_KEY="$(env_get VAULT_UNSEAL_KEY)"
fi
if [[ -n "$UNSEAL_KEY" ]]; then
info "Unsealing openbao..."
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
else
warn "OpenBao is sealed with an unrecoverable key. Resetting OpenBao volume and re-initializing..."
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
docker volume ls -q 2>/dev/null | grep openbao | xargs -r docker volume rm >/dev/null 2>&1 || true
"${COMPOSE[@]}" up -d openbao >/dev/null 2>&1 || true
info "Waiting for fresh openbao container..."
for i in $(seq 1 30); do
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then break; fi
sleep 2
done
info "Initializing fresh openbao..."
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
chmod 600 "$CONFIG_DIR/bao-init.json"
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
info "Unsealing fresh openbao..."
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
fi
fi
export VAULT_TOKEN
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | cut -d'"' -f4 || true)
fi
if [[ -z "$VAULT_TOKEN" ]]; then
VAULT_TOKEN="$(env_get VAULT_TOKEN)"
fi
if [[ -z "$VAULT_TOKEN" ]]; then
die "Could not determine OpenBao VAULT_TOKEN from $CONFIG_DIR/bao-init.json or .env."
fi
# UNSEAL_KEY is only set when OpenBao needed unsealing this run; on a re-run of
# an already-unsealed store it is unset, so guard with ${UNSEAL_KEY:-} (set -u).
if [[ -n "${UNSEAL_KEY:-}" ]]; then
env_upsert VAULT_UNSEAL_KEY "$UNSEAL_KEY"
fi
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
info "Enabling kv-v2 secrets engine at secret/..."
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets enable -path=secret kv-v2 >/dev/null
fi
# ── 3c. OpenBao policies, token role, per-app tokens ─────────────────────────
# Each app gets a least-privilege scoped token (a policy over only its own
# secret/<app>/conf). sso additionally gets the `sso-broker` policy so it can
# mint per-user (user-<uid>) and per-app (app-<name>) tokens at runtime through
# the sso-broker token role. The root VAULT_TOKEN stays in .env for
# setup/maintenance ONLY and is never passed to a service container. Everything
# here is idempotent — re-running setup.sh keeps existing policies/tokens.
# Run a `bao` command inside the openbao container as root.
bao_run() { docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao "$@"; }
# Write an ACL policy from stdin HCL only if it does not already exist.
ensure_policy() {
local name="$1"
# Always (re)write: `bao policy write` is an idempotent overwrite, so this
# applies policy edits on a re-run instead of stranding the old HCL
# forever ("already exists — keeping" silently dropped upgrades — e.g.
# the secret/metadata mount-root list grant added for the /vault fix).
info " writing policy ${name}..."
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
}
# Mint a PERIODIC service token (theta-svc role: orphan, renewable, 768h
# period) for `policy` and persist it to .env as `key`. Periodic tokens have no
# max-TTL death date — each renewal resets the clock — unlike the plain orphan
# tokens minted before this (creation_ttl 768h, dead ~32 days after mint no
# matter what). The bao-renewer sidecar renews them every 12h while the stack
# runs, and every setup.sh re-run renews here too. A valid-but-non-periodic
# token from an older setup.sh is revoked and re-minted as periodic.
ensure_token() {
local key="$1" policy="$2" existing tok lookup
existing="$(env_get "$key")"
if [[ -n "$existing" ]]; then
lookup="$(docker exec -e BAO_TOKEN="$existing" openbao bao token lookup -format=json 2>/dev/null || true)"
if [[ -n "$lookup" ]]; then
# Periodic = minted through the theta-svc role. (OpenBao token lookup
# does not expose a `period` field — the role is the reliable marker;
# renewal behavior confirms the 768h period resets past the original
# creation TTL.)
if echo "$lookup" | grep -q '"role": *"theta-svc"'; then
info " ${key} already minted + periodic (theta-svc) — renewing to reset its clock."
docker exec -e BAO_TOKEN="$existing" openbao bao token renew >/dev/null 2>&1 || true
return 0
fi
info " ${key} is valid but NOT periodic (pre-theta-svc mint; dies at its max TTL) — revoking + re-minting."
bao_run token revoke "$existing" >/dev/null 2>&1 || true
fi
fi
info " minting ${key} (policy=${policy}, role=theta-svc, periodic 768h)..."
tok="$(bao_run token create -role=theta-svc -policy="$policy" -field=token)" \
|| die "failed to mint ${key} (policy=${policy})"
env_upsert "$key" "$tok"
}
# Seed secret/<vault_path> from a /config/*.js module on first run only
# (skipped if the path already exists). Fail-soft: a seed failure leaves the
# app's file-mounted config as the fallback — boot is not blocked.
seed_app_conf() {
local vault_path="$1" mod="$2"
if bao_run kv get "secret/${vault_path}" >/dev/null 2>&1; then
info " secret/${vault_path} already seeded — keeping."
return 0
fi
info "Seeding secret/${vault_path} from ${mod}..."
docker exec sso-manager node -e "console.log(JSON.stringify(require('${mod}')))" 2>/dev/null \
| docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put "secret/${vault_path}" - >/dev/null \
|| warn " could not seed secret/${vault_path} (continuing — app will use its file fallback)"
}
info "Configuring OpenBao policies..."
# sso-broker — sso's authority to read/write its own conf, mint per-user and
# per-app tokens (auth/token/create/sso-broker), and create the matching
# user-<uid> / app-<name> / sso-admin policies. secret/plugins/* holds per-instance
# plugin secrets managed by the SSO plugin system (configurable plugin copies,
# loaded/unloaded at runtime — see sso-manager-node docs/plugins.md).
ensure_policy sso-broker <<'HCL'
path "secret/data/sso-manager/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/sso-manager/conf" { capabilities = ["list", "read", "delete"] }
path "secret/data/proxy/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/proxy/conf" { capabilities = ["list", "read", "delete"] }
path "secret/data/users/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/*" { capabilities = ["list", "read", "delete"] }
path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
path "auth/token/create/sso-broker" { capabilities = ["update"] }
path "auth/token/create/sso-app" { capabilities = ["update"] }
path "auth/token/renew-accessor" { capabilities = ["update"] }
path "auth/token/revoke-accessor" { capabilities = ["update"] }
path "auth/token/lookup-accessor" { capabilities = ["update"] }
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
HCL
# sso-admin — admin users in the vault UI: read/write/list everything under secret/.
# The bare `secret/metadata` grant lets an admin LIST the KV mount root (the
# top-level dirs); `secret/metadata/*` covers nested paths but not the root
# itself, so without it the /vault secrets list 403s.
ensure_policy sso-admin <<'HCL'
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }
HCL
# proxy / jump-host — read only their own boot conf.
ensure_policy proxy <<'HCL'
path "secret/data/proxy/conf" { capabilities = ["read"] }
path "secret/data/proxy/dns-providers/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/proxy/dns-providers/*" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/proxy/conf" { capabilities = ["read", "list"] }
HCL
ensure_policy jump-host <<'HCL'
path "secret/data/jump-host/conf" { capabilities = ["read"] }
path "secret/metadata/jump-host/conf" { capabilities = ["read", "list"] }
HCL
# sso-broker token role: lets sso mint user-*/app-*/sso-admin tokens. Orphan,
# renewable, 24h period. Wildcards need allowed_policies_glob — allowed_policies
# is exact-match only.
info "Configuring sso-broker token role..."
if ! bao_run read auth/token/roles/sso-broker >/dev/null 2>&1; then
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-broker - <<'JSON' >/dev/null
{"allowed_policies":["sso-admin"],"allowed_policies_glob":["user-*","app-*"],"orphan":true,"renewable":true,"token_period":"24h"}
JSON
else
info " token role sso-broker already exists — keeping."
fi
# sso-app token role: external-app tokens minted from the sso vault UI. Periodic
# 768h (NOT the broker's 24h) — an app token is a long-lived credential; with a
# 24h period any downstream app that didn't renew daily silently died. A 768h
# period keeps it alive as long as the app renews (or is re-minted) at least
# monthly: `bao token renew-self` / POST /v1/auth/token/renew-self.
info "Configuring sso-app token role..."
if ! bao_run read auth/token/roles/sso-app >/dev/null 2>&1; then
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-app - <<'JSON' >/dev/null
{"allowed_policies_glob":["app-*"],"orphan":true,"renewable":true,"token_period":"768h"}
JSON
else
info " token role sso-app already exists — keeping."
fi
# theta-svc token role: the services' own tokens (SSO/PROXY/JUMP_VAULT_TOKEN).
# Periodic 768h so they can be renewed forever (the bao-renewer sidecar renews
# every 12h; each setup.sh re-run renews too). allowed_policies is exact-match:
# exactly the three service policies, nothing else.
info "Configuring theta-svc token role..."
if ! bao_run read auth/token/roles/theta-svc >/dev/null 2>&1; then
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/theta-svc - <<'JSON' >/dev/null
{"allowed_policies":["sso-broker","proxy","jump-host"],"orphan":true,"renewable":true,"token_period":"768h"}
JSON
else
info " token role theta-svc already exists — keeping."
fi
info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..."
ensure_token SSO_VAULT_TOKEN sso-broker
ensure_token PROXY_VAULT_TOKEN proxy
ensure_token JUMP_VAULT_TOKEN jump-host
info "OpenBao secrets configured:"
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
info " token roles: sso-broker (user-*/app-*/sso-admin, 24h period), sso-app (app-*, 768h period), theta-svc (service tokens, 768h period)"
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env (periodic; renewed by bao-renewer)"
# bao-renewer: renews the three periodic service tokens every 12h so they never
# hit their period boundary while the stack is running. Recreated (not just
# started) so it always picks up freshly re-minted tokens from .env.
info "Starting bao-renewer (service-token renewal sidecar)..."
"${COMPOSE[@]}" up -d --force-recreate bao-renewer
# ── 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
@@ -627,6 +983,7 @@ backup_before_rebuild
# docker-compose.yml and sso-manager-node's Dockerfile.openldap.
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
export SSO_GIT_COMMIT
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build sso-manager
@@ -642,6 +999,28 @@ for i in $(seq 1 60); do
sleep 2
done
# After a full OpenBao reset, the Redis-cached per-user/admin vault tokens (in
# the persisted sso-data volume) reference the old, now-wiped store — drop them
# so the broker re-mints fresh tokens against the new instance. Belt-and-
# suspenders: the broker also always reconciles policy content before serving a
# token, but a token minted by the previous OpenBao instance is simply invalid
# there, so a cache flush is required after a reset.
if [[ "$RESET_OPENBAO" == "1" ]]; then
info " clearing cached vault tokens (old OpenBao instance)..."
docker exec sso-manager sh -c "redis-cli EVAL \"for _,k in ipairs(redis.call('keys','vault_token:*')) do redis.call('del',k) end\" 0" \
>/dev/null 2>&1 || warn " could not flush Redis vault-token cache (will re-mint on next access)"
fi
info "Seeding app configs into OpenBao (idempotent)..."
# sso-manager/conf holds the operator-set LDAP/SMTP/jwtSecret values — sso has
# no bootstrap-generated creds, so the file is the complete source of truth.
seed_app_conf sso-manager/conf /config/sso-secrets.js
# proxy/conf is seeded from the operator file (placeholder OAuth creds); the
# bootstrap (step 5) then writes the real generated OAuth client creds into
# OpenBao over this. proxy boots at step 6, after bootstrap, so it sees the
# real values.
seed_app_conf proxy/conf /config/proxy-secrets.js
# Read the summary values (hosts, admin, base DN) back from ./config via the
# running container's node — works whether ./config was generated or pre-existing.
read_config_kv() {
@@ -672,7 +1051,11 @@ info " Admin uid: ${ADMIN_UID}"
# ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ──
# 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.
# generated OAuth client creds back into /config/proxy-secrets.js AND into
# OpenBao (secret/proxy/conf, secret/jump-host/conf) so the proxy + jump host
# load them from OpenBao at boot. The root VAULT_TOKEN is passed on this one
# exec so bootstrap can write those paths; it is never handed to a service
# container.
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
# Host facts for the directory seed — collected HERE (on the host; inside the
# container hostname/uname describe the container, not the machine). Same
@@ -692,8 +1075,9 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
-e STACK_HOST_OS="$STACK_HOST_OS" \
-e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \
-e CFG_JUMP_HOST_ENABLED="${CFG_JUMP_HOST_ENABLED:-}" \
-e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \
-e VAULT_ADDR=http://openbao:8200 \
-e VAULT_TOKEN="$VAULT_TOKEN" \
sso-manager node /bootstrap/bootstrap.js) \
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}"
@@ -712,6 +1096,7 @@ fi
# PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above.
PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)"
export PROXY_GIT_COMMIT
env_upsert PROXY_GIT_COMMIT "$PROXY_GIT_COMMIT"
info "Building + starting proxy (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build proxy
@@ -746,7 +1131,7 @@ async function ensureHost(host, ip, targetPort) {
host: host,
ip: ip,
targetPort: targetPort,
forcessl: true,
forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ),
targetssl: false,
sso_enabled: false,
created_by: 'setup.sh',
@@ -769,36 +1154,41 @@ 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
# ── 7b. Build + start the SSH jump host ──────────────────────────────────────
# The jump host is a core component (no longer optional). The bootstrap (step 5)
# already wrote ./config/jump-secrets.js (minted API token + LDAP admin bind) and
# mirrored it into OpenBao. Build/start the service, wait for its web /health,
# and register its web UI hostname as a proxy Host so https://<JUMP_HOST> routes.
JUMP_HOST="${CFG_JUMP_HOST:-jump.${SSO_HOST#sso.}}"
JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)"
export JUMP_GIT_COMMIT
env_upsert JUMP_GIT_COMMIT "$JUMP_GIT_COMMIT"
# Seed jump-host/conf from the file bootstrap just wrote (it mints the API
# token + OAuth client into /config/jump-secrets.js at step 5). bootstrap also
# writes this to OpenBao directly, so this is a fallback for when bootstrap's
# jump provisioning warned-but-continued.
seed_app_conf jump-host/conf /config/jump-secrets.js
info "Building + starting jump-host..."
"${COMPOSE[@]}" up -d --build jump-host
info "Waiting for jump-host to be healthy..."
for i in $(seq 1 60); do
if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then
info "jump-host is healthy."; break
fi
if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi
sleep 2
done
info "Waiting for jump-host to be healthy..."
for i in $(seq 1 60); do
if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then
info "jump-host is healthy."; break
fi
if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi
sleep 2
done
info "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..."
JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF || true
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' });
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ), targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
}
process.exit(0);
@@ -806,26 +1196,170 @@ const {Host} = require('/app/models').models;
})();
NODEEOF
)
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
info "Setting up theta-agent on the host..."
(
cd theta-agent || exit 0
# Install the prebuilt binary that ships in the theta-agent submodule (the
# repo's own install.sh uses the same release binary). We do NOT build from
# source here: a previous `go build -o theta-agent main.go websocket.go
# config.go` omitted executor.go/telemetry.go, failed to compile, and was
# silently skipped, so the agent was never installed.
if [[ ! -f "theta-agent-linux-amd64" ]]; then
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
else
info " Installing prebuilt theta-agent binary..."
if [[ -x "theta-agent-linux-amd64" ]]; then
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
sudo mkdir -p /etc/theta42
if [[ ! -f /etc/theta42/agent.yml ]]; then
sudo cp agent.yml.example /etc/theta42/agent.yml
AGENT_TOKEN="$(rand_hex 16)"
sudo sed -i "s/REPLACE_WITH_AGENT_TOKEN/$AGENT_TOKEN/" /etc/theta42/agent.yml
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
else
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
fi
fi
# Stop a running agent before overwriting its binary (cp into a
# running executable fails with "Text file busy" on a re-install).
sudo systemctl stop theta-agent.service 2>/dev/null || true
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
sudo chmod +x /usr/local/bin/theta-agent
sudo bash -c "cat <<'EOF' > /etc/systemd/system/theta-agent.service
[Unit]
Description=Theta Agent
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/theta-agent
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF"
sudo systemctl daemon-reload
sudo systemctl enable --now theta-agent.service
info " theta-agent installed and started."
fi
fi
)
else
info "theta-agent installation skipped (CFG_THETA_AGENT_ENABLE=0)."
fi
# ── 7d. Configure theta-agent integration with this host ─────────────────────
# Non-interactive configuration driven by setup.env variables:
# CFG_THETA_AGENT_ENABLE (default: 1) - Install/configure theta-agent
# CFG_THETA_AGENT_LDAP_AUTH (default: 1) - Configure LDAP authentication via ldap-client
# CFG_THETA_AGENT_FULL_CONTROL (default: 1) - Enable all agent capabilities
# Only runs if theta-agent was installed (section 7c) or already exists.
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]; then
info "Configuring theta-agent integration with this host..."
# Default to enabled unless explicitly disabled
CFG_THETA_AGENT_LDAP_AUTH="${CFG_THETA_AGENT_LDAP_AUTH:-1}"
CFG_THETA_AGENT_FULL_CONTROL="${CFG_THETA_AGENT_FULL_CONTROL:-1}"
if [[ "$CFG_THETA_AGENT_LDAP_AUTH" == "1" ]]; then
info " Configuring LDAP authentication for this host..."
# ldap-client/index.sh refuses to run without ./ldap.vars, which is
# gitignored and never shipped in the checkout (it holds a real bind
# password). On the agent-enrollment path we generate it from the stack's
# own config so the host can actually enroll; an operator-provided
# ldap.vars (cp ldap.vars.template ldap.vars + edit) is always kept.
if [[ ! -f ldap-client/ldap.vars ]]; then
info " Generating ldap-client/ldap.vars from the stack config..."
# CFG_* first-run vars may be unset on a re-run (ensure_config returns
# early once sso-secrets.js exists), so fall back to reading the real
# values from the operator-owned sso-secrets.js. All `:-` guarded so a
# missing value degrades to an empty ldap.vars field, not a set -u abort.
ldap_base_dn="${CFG_BASE_DN:-$(sso_secrets_get ldapBaseDn)}"
ldap_site="${CFG_SITE_NAME:-$(sso_secrets_get siteName)}"
ldap_bind_pass="${CFG_SVC_PASS:-$(sso_secrets_get_top serviceAccountPass)}"
sso_host="${CFG_SSO_HOST:-$(sso_secrets_get ssoHost)}"
# The LDAP server is co-located with the stack on THIS host, so the
# host must reach it over the loopback / a local address -- NEVER the
# public domain (sso.<domain>), which cannot route back to the 389/636
# ports through NAT. localhost is fine because the generated sssd.conf
# sets ldap_tls_reqcert=never (hostname verification is off). An
# operator may override with CFG_LDAPS_HOST (an internal hostname/IP).
ldaps_host="${CFG_LDAPS_HOST:-localhost}"
cat > ldap-client/ldap.vars <<LDAPVARS
export ldap_host="${ldaps_host}"
export ldap_base_dn="${ldap_base_dn}"
export ldap_bind_dn="cn=ldapclient,ou=people,${ldap_base_dn}"
export ldap_bind_password="${ldap_bind_pass}"
export sso_url="https://${sso_host}"
export sso_token=""
export ldap_location="${ldap_site:-local}"
# Groups that grant SSH/access on this host (docs/GROUPS.md §8): the site's
# all-hosts aggregate, this host's own access group, and god_admin.
ldap_access_groups=( "site_\${ldap_location}_hosts_access" "site_\${ldap_location}_host_\$(hostname)_access" "god_admin" )
LDAPVARS
else
info " ldap-client/ldap.vars exists -- keeping it"
fi
(
cd ldap-client || exit 0
if [[ -x "index.sh" ]]; then
bash index.sh --non-interactive 2>/dev/null || warn " ldap-client enrollment failed (continuing)..."
fi
)
else
info " LDAP authentication configuration skipped (CFG_THETA_AGENT_LDAP_AUTH=0)."
fi
if [[ "$CFG_THETA_AGENT_FULL_CONTROL" == "1" ]]; then
info " Configuring theta-agent with full host control capabilities..."
if [[ -f /etc/theta42/agent.yml ]]; then
sudo sed -i 's/arbitrary_bash: false/arbitrary_bash: true/' /etc/theta42/agent.yml
# service_control is a []string allowlist (NOT a bool) — setting it to
# `true` makes the agent fail YAML decode and crash-loop. There is no
# wildcard; leave the operator's list (or the [] default = deny all)
# alone and document how to enable specific services.
# sudo sed -i 's/service_control: .*/service_control: true/' ...
sudo sed -i 's/reboot: false/reboot: true/' /etc/theta42/agent.yml
sudo sed -i 's/configure_ldap: false/configure_ldap: true/' /etc/theta42/agent.yml
info " (service_control left as its allowlist; set e.g. service_control: [\"nginx\"] in /etc/theta42/agent.yml to permit managing specific services)"
info " theta-agent full control enabled. Restarting service..."
sudo systemctl restart theta-agent.service
else
warn " /etc/theta42/agent.yml not found. Full control not configured."
fi
else
info " theta-agent running with limited capabilities (CFG_THETA_AGENT_FULL_CONTROL=0)."
fi
else
info " theta-agent configuration skipped (agent not installed or CFG_THETA_AGENT_ENABLE=0)."
fi
# ── 8. Summary ───────────────────────────────────────────────────────────────
echo
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
printf '\033[1;34m[setup]\033[0m \033[1;32mDone. Your SSO + proxy stack is up.\033[0m\n'
echo
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 credentials are in ./config/sso-secrets.js:"
echo " user: ${ADMIN_UID}"
echo " pass: bootstrap.adminPass"
echo " pass: ${CFG_ADMIN_PASS:-<see ./config/sso-secrets.js>}"
echo
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
echo " user: proxyadmin2"
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
set -e
echo "=== Starting theta-suite Integration Tests ==="
echo "=> Cleaning up any existing containers and volumes..."
docker-compose down -v
echo "=> Running setup.sh to initialize environment..."
# Run setup non-interactively if possible (we might need to export some env vars)
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-suite to test integration between all the include projects".
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
# Actually, setup.sh initializes Vault. If we don't run it, Vault is sealed!
# Let's just write a curl test that checks if the containers start.
docker-compose up -d
echo "=> Waiting for services to become healthy..."
sleep 15 # Give time for containers to spin up
# Test proxy
echo "=> Testing Proxy..."
if ! curl -sS -o /dev/null -w "%{http_code}" http://localhost | grep -q "406"; then
echo "❌ Proxy failed to respond with 406 Not Acceptable on port 80 (default behavior)"
exit 1
fi
echo "✅ Proxy responds on port 80"
# Test SSO Manager Node
echo "=> Testing SSO Manager..."
if ! curl -sS -f -o /dev/null http://localhost:3001; then
echo "❌ SSO Manager failed to respond on port 3001"
exit 1
fi
echo "✅ SSO Manager responds on port 3001"
echo "=== All integration tests passed! ==="
docker-compose down -v
exit 0
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
'use strict';
// Regression guard for bootstrap.js's generated jump-secrets.js template:
// its ldap block must use ldaps:// (implicit TLS, :636), never ldap:// (:389),
// as long as tlsOptions is set alongside it.
//
// ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL
// scheme, and jump-host's LDAP client always sets tlsOptions -- so ldap://
// + tlsOptions opens a raw TLS handshake against a port serving plaintext
// LDAP. The server silently drops the connection before any LDAP message
// parses, and every operation (getUser, checkPassword, ...) then fails
// identically -- indistinguishable from a wrong password. This shipped once
// (every SSH login to jump-host failed, for any account, any password) before
// being root-caused against a real deployment. Static, not a require()+exec
// of bootstrap.js, because bootstrap.js is a self-running provisioning script
// with real side effects (LDAP writes, API calls), not a library.
const fs = require('fs');
const path = require('path');
const BOOTSTRAP_PATH = path.join(__dirname, '..', 'bootstrap', 'bootstrap.js');
const src = fs.readFileSync(BOOTSTRAP_PATH, 'utf8');
// Isolate the generated jump-secrets.js template (the backtick string
// assigned to `body` inside writeJumpSecrets) rather than scanning the whole
// file, so this only ever looks at what's actually written to the deployed
// config -- not, say, a comment or an unrelated ldap:// URL elsewhere.
// bootstrap.js's own source has literal backslash-t escape sequences inside
// the backtick string (they only become real tabs when the template
// literal is actually evaluated) -- so these patterns match `\t` as two
// literal characters, not a real tab byte.
const bodyMatch = /const body = `([\s\S]*?)`;\n\tfs\.writeFileSync\(JUMP_SECRETS/.exec(src);
if (!bodyMatch) {
console.error('check_jump_ldap_tls: could not locate the jump-secrets.js template in bootstrap.js — did writeJumpSecrets change shape?');
process.exit(1);
}
const template = bodyMatch[1];
// Bounded by the next top-level key (sso:) rather than the ldap block's own
// closing brace, which is more robust to exactly how it's indented/escaped.
const ldapBlockMatch = /ldap:\s*\{([\s\S]*?)\\tsso:\s*\{/.exec(template);
if (!ldapBlockMatch) {
console.error('check_jump_ldap_tls: could not find the ldap: {...} block in the jump-secrets.js template.');
process.exit(1);
}
const ldapBlock = ldapBlockMatch[1];
const hasTlsOptions = /tlsOptions\s*:/.test(ldapBlock);
const urlMatch = /url:\s*'([^']+)'/.exec(ldapBlock);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
console.error('check_jump_ldap_tls: no url found in the ldap block.');
process.exit(1);
}
if (hasTlsOptions && !url.startsWith('ldaps://')) {
console.error(
`check_jump_ldap_tls: jump-secrets.js template sets tlsOptions but url is "${url}" (not ldaps://). ` +
'This is the exact bug that broke every SSH login to jump-host -- see the comment above this check.'
);
process.exit(1);
}
console.log(`check_jump_ldap_tls: OK (url=${url}, tlsOptions=${hasTlsOptions})`);
+40
View File
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert');
test('Integration Test Suite', async (t) => {
await t.test('SSO Manager should be running and healthy', async () => {
const res = await fetch('http://localhost:3001/health');
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.status, 'ok');
});
await t.test('Proxy should be running and route to SSO Manager', async () => {
// Testing the proxy routes traffic to SSO manager
const res = await fetch('http://sso.localtest.me/.well-known/openid-configuration');
assert.ok(res.status === 200 || res.status === 301);
const body = await res.json();
assert.ok(body.issuer);
});
await t.test('Proxy Management API should be running', async () => {
const res = await fetch('http://localhost:3000/health');
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.status, 'ok');
});
await t.test('OpenBao should be running and healthy', async () => {
// Port 8080 is mapped to OpenBao's 8200 in docker-compose.yml
const res = await fetch('http://localhost:8080/v1/sys/health');
assert.ok(res.status === 200 || res.status === 501); // 501 means not initialized/sealed, but responsive
});
await t.test('SSO Manager should proxy to OpenBao (integration test)', async () => {
// Test if SSO Manager proxies to OpenBao
// Without authentication, this should return 401 Unauthorized from SSO Manager's middleware
const res = await fetch('http://localhost:3001/api/vault/sys/health');
assert.strictEqual(res.status, 401);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"name": "theta-env-integration-tests",
"version": "1.0.0",
"description": "Automated integration tests for theta-env projects",
"scripts": {
"test": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --test *.test.js"
}
}
Submodule
+1
Submodule theta-agent added at 52379c2434