Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 60ba9ba989 docs: fix quickstart drift, add LICENSE, document admin bypass and LDAPS cert mount for public release
Cleanup pass ahead of the public release announcement:

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:00:32 -04:00
33 changed files with 344 additions and 3569 deletions
+5 -14
View File
@@ -1,4 +1,4 @@
# theta-suite — unified SSO Manager + Proxy deployment.
# theta-env — 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.
@@ -14,8 +14,7 @@
LDAP_BASE_DN=dc=example,dc=com
# DNS domain (dc=foo,dc=bar -> foo.bar). Leave blank to derive from LDAP_BASE_DN.
LDAP_DOMAIN=
# LDAP admin password. MUST be changed. Leave blank and setup.sh will generate one.
LDAP_ADMIN_PASS=CHANGE-ME
LDAP_ADMIN_PASS=change-me-ldap-admin-password
ORG_NAME="My Org"
# ── Public hostnames (REQUIRED) ───────────────────────────────────────────────
@@ -31,15 +30,13 @@ PROXY_HOST=proxy.example.com
# app_sso_oauth_admin, and logs in as them to register the proxy OAuth client.
# Re-running setup.sh resets this password to BOOTSTRAP_ADMIN_PASS.
BOOTSTRAP_ADMIN_UID=admin
# First admin password. MUST be changed. Leave blank and setup.sh will generate one.
BOOTSTRAP_ADMIN_PASS=CHANGE-ME
BOOTSTRAP_ADMIN_PASS=change-me-admin-password
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
# ── Proxy LDAP service account (created by the bootstrap) ────────────────────
# The proxy binds to LDAP as cn=ldapclient,ou=people,<base> with this password.
# Re-running setup.sh resets it to LDAP_SERVICE_PASS.
# LDAP service-account password. MUST be changed. Leave blank and setup.sh will generate one.
LDAP_SERVICE_PASS=CHANGE-ME
LDAP_SERVICE_PASS=change-me-ldap-service-password
# ── OAuth JWT secret (REQUIRED — persist it) ────────────────────────────────
# Signs the SSO's access/refresh tokens. Generate with: openssl rand -hex 32
@@ -76,10 +73,4 @@ MGMT_BIND=0.0.0.0
# Defaults to LDAP_DOMAIN. Set to the hostname the proxy connects via
# (sso-manager inside the docker net uses the service name, which is in the
# cert's SAN, so the default is usually fine).
LDAP_CERT_CN=
# ── Optional: LDAPS hostname shown on the SSO /integrations page ────────────────
# Leave blank to derive from the public SSO host (SSO_HOST). Set an internal-only
# name like 'ldap.internal.example.com' or 'sso-manager' so direct-LDAP clients
# don't need a public 636 port forward. See docs/ldap.md for network layouts.
LDAPS_HOST=
LDAP_CERT_CN=
-46
View File
@@ -1,46 +0,0 @@
name: Lint
# 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, plus a static
# consistency check on the config bootstrap.js generates for jump-host
# (test/check_jump_ldap_tls.js).
on:
pull_request:
branches:
- master
push:
branches-ignore:
- master
jobs:
shellcheck:
name: Shellcheck setup.sh
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Syntax check
run: bash -n setup.sh
- name: Shellcheck
run: shellcheck -S warning setup.sh
bootstrap-syntax:
name: Syntax check bootstrap.js
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Syntax check
run: node --check bootstrap/bootstrap.js
- name: Jump-host LDAP config consistency
run: node test/check_jump_ldap_tls.js
+3 -10
View File
@@ -5,12 +5,8 @@
config/
backups/
# .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.
# Legacy .env / proxy.env (no longer used — config is in ./config/). Still
# ignored in case a migrated deployment hasn't deleted them yet.
.env
proxy.env
@@ -24,7 +20,4 @@ setup.env
*.ldif
# Docker Compose runtime artifacts
*.log
# Jekyll build output (docs/ site) — generated, not committed.
docs/_site
*.log
-7
View File
@@ -4,10 +4,3 @@
[submodule "proxy"]
path = proxy
url = https://github.com/theta42/proxy.git
[submodule "jump-host"]
path = jump-host
url = https://github.com/theta42/jump-host.git
branch = master
[submodule "ldap-client"]
path = ldap-client
url = https://github.com/theta42/ldap-client.git
-1269
View File
File diff suppressed because it is too large Load Diff
+46 -111
View File
@@ -1,10 +1,9 @@
# theta-suite
# theta-env
The whole theta42 identity + access stack in one repo, brought up with a single
command — for home labs and small businesses.
It composes four applications around a shared [OpenBao](https://openbao.org/)
secrets store, brought up with one command:
It wires together two projects that already work on their own:
- **[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
@@ -12,36 +11,19 @@ secrets store, brought up with one command:
- **[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.
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.
Each project still runs **standalone** (`docker compose up` in its own folder);
this repo just composes them and automates the first-run glue so they find each
other.
**Documentation:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
## Screenshots
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
| SSO Manager Dashboard | Proxy Hosts |
| --- | --- |
| [![SSO Manager dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
## 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](#)).
**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.
```
┌──────────────────────────────────────────────┐
@@ -68,10 +50,6 @@ It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). Legacy apps can still bind to LDAPS on the SSO
directly.
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
---
## Before you begin
@@ -86,10 +64,10 @@ real TLS certificates for it via Let's Encrypt. A `.local` or made-up name only
gets you a self-signed cert (browsers will warn — fine for testing, painful for
daily use).
The domain is the **one** value you set in `setup.env` (e.g.
`CFG_DOMAIN=lab.example.com`) — see *Quickstart*. The SSO/proxy hostnames
default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN
(`dc=lab,dc=example,dc=com`) is built from it automatically.
The domain is the **one** value you set in `setup.env` (as the LDAP base DN,
e.g. `CFG_BASE_DN=dc=lab,dc=example,dc=com` for `lab.example.com`) — see
*Quickstart*. The SSO/proxy hostnames default to `sso.<domain>` /
`proxy.<domain>`, derived from it.
### 2. At least two hostnames, pointing at your public IP
@@ -129,14 +107,9 @@ 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) — for direct-LDAP clients on other machines (Linux hosts
via PAM/SSSD, LDAP-native apps). The proxy itself reaches LDAP over the
internal Docker network, so you do **not** need to expose 636 for the stack
to work.
**Do not forward 636 to the public internet.** If you need LAN clients to bind
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
SAN. The default shows the public SSO hostname, which implies a public route.
- **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.
### 4. Docker + Docker Compose
@@ -148,15 +121,14 @@ standalone (`docker-compose`) both work.
## Quickstart
```bash
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
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
```
Your domain is entered **once** in `setup.env` (e.g.
`CFG_DOMAIN=lab.example.com`) — the LDAP base DN (`dc=lab,dc=example,dc=com`)
is derived from it, however many labels it has. The
Your domain is entered **once**, as the LDAP base DN in `setup.env` (e.g.
`CFG_BASE_DN=dc=lab,dc=example,dc=com` for the domain `lab.example.com`). The
first `./setup.sh` reads `setup.env` and generates `./config/sso-secrets.js` +
`./config/proxy-secrets.js` with that domain filled in everywhere (hostnames
default to `sso.<domain>` / `proxy.<domain>`) plus random secrets, then builds
@@ -179,28 +151,12 @@ operator-owned and `setup.env` is ignored.
- registers the proxy as an OIDC client in the SSO and **writes the generated
client id + secret back into `./config/proxy-secrets.js`**.
4. Builds + starts the proxy container, waits for it to be healthy.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy
(directly via its Host model, inside the proxy container) — the proxy
routes every hostname it serves off a Host record, including its own
management UI and the SSO's UI, so without this step those two URLs
would 404. Idempotent; skips a host that already exists.
6. Prints your first admin login + the public URLs.
5. Prints your first admin login + the public URLs.
### Configuration & secrets — OpenBao + `./config/`
### Configuration — `./config/` (no `.env` files)
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:
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
read by each app's `@simpleworkjs/conf` from a symlinked `secrets.js`:
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
@@ -211,15 +167,11 @@ via the `CONF_SECRETS` env var:
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, 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`.
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`.
> **Migrating from an older `.env`-based deployment?** If `.env` and/or
> `proxy.env` exist when you first run `./setup.sh`, it migrates them into
@@ -238,10 +190,9 @@ override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
- **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 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.
- **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.
### API tokens (personal access tokens)
@@ -349,11 +300,8 @@ 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 — ./config/ (the seed/fallback; OpenBao is authoritative)
# Secrets
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
@@ -404,15 +352,9 @@ Redis and are preserved by the volume.
---
## Running a component individually
## Running each project standalone
> The integrated stack (`./setup.sh`) is the supported path. The per-project
> commands below are for the advanced case of running one component on its own
> (separate host, no orchestrator) — you then manage secrets from the
> `config/*-secrets.js` file only (no shared OpenBao) and do the OIDC/LDAP wiring
> by hand. See [docs/standalone.md](docs/standalone.md).
Each submodule builds and runs on its own:
The two submodules work on their own — this repo just composes them:
- **SSO Manager alone**:
```bash
@@ -498,8 +440,8 @@ exactly in the bootstrap) so the SSO can verify them on bind.
## Repo layout
```
theta-suite/
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN
theta-env/
├── setup.env.example # first-run config template — cp to setup.env, set CFG_BASE_DN
├── config.example/ # committed annotated config templates (copy to ./config/)
├── docker-compose.yml # sso-manager + proxy on one bridge net
├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups)
@@ -513,14 +455,7 @@ theta-suite/
gitignored `./config/` (`sso-secrets.js` + `proxy-secrets.js`) and snapshots to
the gitignored `./backups/` before each rebuild.
`./setup.sh` updates both submodules to their latest `vX.Y.Z` release tag
before building — not the tip of `master` — so each run builds the newest
tagged release of each app, not whatever's most recently merged upstream. To
lock to the pinned commits (offline rebuild, or a deliberate pin), run
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-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)
— for what changed inside the apps themselves).
`./setup.sh` updates both submodules to the latest of their tracked remote
branch before building, so each run builds current upstream — no manual
`git submodule update --remote` needed. To lock to the pinned commits (offline
rebuild, or a deliberate pin), run `SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
+10 -424
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/*
* theta-suite bootstrap — runs inside the sso-manager container to wire the
* theta-env 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,13 +23,6 @@
* 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
@@ -51,15 +44,8 @@ const fs = require('fs');
const sso = require('/config/sso-secrets.js');
const proxy = require('/config/proxy-secrets.js');
function requireConf(value, name) {
if (value === undefined || value === null || value === '' || value === 'CHANGE-ME') {
throw new Error(`${name} is not configured in /config/sso-secrets.js`);
}
return value;
}
const BASE_DN = requireConf((sso.stack && sso.stack.ldapBaseDn), 'stack.ldapBaseDn');
const ADMIN_PASS = requireConf((sso.ldap && sso.ldap.bindPassword), 'ldap.bindPassword');
const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com';
const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin';
const BIND_DN = `cn=admin,${BASE_DN}`;
const LDAP_URL = 'ldap://localhost:389';
@@ -67,9 +53,9 @@ const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
// The first admin *user's* password (cn=<uid>,ou=people,<base>). Distinct from
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password —
// two different accounts, two different secrets.
const ADMIN_USER_PASS = requireConf((sso.bootstrap && sso.bootstrap.adminPass), 'bootstrap.adminPass');
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin';
const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || '';
const SVC_PASS = requireConf(sso.serviceAccountPass, 'serviceAccountPass');
const SVC_PASS = sso.serviceAccountPass || 'service';
const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com';
const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com';
@@ -94,44 +80,6 @@ const ADMIN_GROUPS = ['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) {
@@ -274,15 +222,14 @@ async function listClients(token) {
return (data && data.results) || [];
}
async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
async function createClient(token) {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: o.name,
description: o.description,
redirect_uris: o.redirect_uris,
name: CLIENT_NAME,
description: 'theta-env proxy (auto-registered)',
redirect_uris: [REDIRECT_URI],
scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [],
}),
@@ -295,7 +242,7 @@ async function createClient(token, opts) {
const id = (data.results && data.results.client_id) || data.client_id;
const secret = data.client_secret;
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
log(`Created OAuth client ${o.name} (${id})`);
log(`Created OAuth client ${CLIENT_NAME} (${id})`);
return { id, secret };
}
@@ -314,185 +261,6 @@ async function rotateClient(token, id) {
return { id, secret: data.client_secret };
}
// ── 5. Seed the SSO directory with the stack's own resources ────────────────
// The Directory page (site → host → service hierarchy) starts empty even
// though this stack knows exactly what it deployed. Seed it: one site (the
// domain), one host (the box this stack runs on), and the two services
// (SSO Manager + proxy), then link the proxy's OAuth client under its
// service. Idempotent — existing slugs are left untouched, so operator
// edits (renames, metadata, extra resources) survive re-runs. Failures
// here only warn: the directory is a nicety, never worth failing a
// bring-up over (e.g. an older sso-manager image without /api/directory).
const DOMAIN = (sso.stack && sso.stack.ldapDomain) || '';
const ORG = sso.name || 'SSO Manager';
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
async function dirGet(token, path) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/directory-admin/${path} failed (${res.status})`);
return res.json();
}
async function dirPost(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
async function dirPut(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'PUT',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`PUT /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
// The site the stack registers itself under. Also the default "Location
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
const SITE_NAME = (sso.stack && sso.stack.siteName) || 'local';
// Host facts, collected by setup.sh ON THE HOST (inside this container
// hostname/uname describe the container) and passed via the exec env. Same
// fields ldap-client/index.sh registers, so stack hosts and ldap-client-
// joined hosts carry identical metadata.
const HOST_FACTS = {
name: process.env.STACK_HOST_NAME || '',
ip: process.env.STACK_HOST_IP || '',
mac: process.env.STACK_HOST_MAC || '',
os: process.env.STACK_HOST_OS || '',
kernel: process.env.STACK_HOST_KERNEL || '',
};
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Create a resource unless its slug (or a legacy alternate from an earlier
// seed layout) already exists. On an existing resource, seed metadata keys
// it doesn't have yet are filled in — operator-set values always win and
// are never overwritten.
async function ensure(kind, name, slug, parentId, metadata, altSlugs) {
const slugs = [slug, ...(altSlugs || [])];
const found = resources.find((r) => slugs.includes(r.slug));
if (found) {
const have = found.metadata || {};
const missing = Object.entries(metadata || {})
.filter(([k, v]) => (have[k] === undefined || have[k] === '') && v !== '');
if (missing.length) {
const merged = { ...have };
for (const [k, v] of missing) merged[k] = v;
// metadata-only PUT: no kind/hostId in the body, so the route's
// parent validation and edge rewiring are not triggered.
await dirPut(token, `resources/${found.id}`, { metadata: merged });
found.metadata = merged;
log(` directory: ${kind} '${found.slug}' exists — filled ${missing.map(([k]) => k).join(', ')}`);
} else {
log(` directory: ${kind} '${found.slug}' exists — keeping`);
}
return found;
}
const body = { kind, name, slug, metadata: metadata || {} };
if (parentId) body.hostId = parentId; // POST creates the parent edge
const created = (await dirPost(token, 'resources', body)).results;
resources.push(created);
log(` directory: created ${kind} '${slug}'`);
return created;
}
// site_<name> / host_<name> slug convention matches ldap-client/index.sh.
// altSlugs grandfather in the layout the first seed release used.
const site = await ensure('site', SITE_NAME, `site_${slugify(SITE_NAME)}`, null,
{ isCurrentSite: true },
[slugify(DOMAIN || ORG)]);
const hostSlug = HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : 'stack-host';
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
subType: 'linux',
ip: HOST_FACTS.ip,
macAddress: HOST_FACTS.mac,
os: HOST_FACTS.os,
kernel: HOST_FACTS.kernel,
}, ['stack-host']);
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
address: `https://${SSO_HOST}`,
port: 3001,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'web',
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
address: `https://${PROXY_HOST}`,
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'web',
});
// OpenLDAP is independently consumed — Linux hosts authenticate against it
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
// (see the SSO's /integrations page) — so it gets its own entry. Advertise
// the operator-configured LDAPS hostname when set, else the SSO host.
// The bundled slapd's image/config live in sso-manager-node.
const LDAPS_HOST = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
await ensure('service', 'OpenLDAP Directory', 'openldap', host.id, {
address: `ldaps://${LDAPS_HOST}:636`,
port: 389,
externalPort: 636,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'openldap',
});
// Wildcard address: OpenResty fronts every host under the domain (same
// */** wildcard convention the proxy's Host records use). Its config lives
// in the proxy repo (ops/nginx_conf).
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
port: 443,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'openresty',
});
// SSH jump host service (core component — always registered).
let jumpSvc = null;
{
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
subType: 'ssh',
});
}
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
await linkOauthClient(clientId, psvc, 'proxy');
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
// (which reads that file) can use them. Only the clientId/clientSecret lines
// are touched; the rest of the file (operator edits, comments) is preserved.
@@ -523,148 +291,6 @@ function writeProxyCreds(id, secret) {
}
}
// ── 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';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
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();
const raw = data.token || (data.results && data.results.token) || data.raw_token;
if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`);
return raw;
}
// The generated file is "complete" only if it has BOTH a real directory API
// token AND an OIDC client id — an existing file from the pre-OIDC layout (a
// token but no oidc block) is regenerated so the web UI's SSO login works.
function jumpFileComplete() {
try {
const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src);
return hasToken && hasOidc;
} catch (_) { return false; }
}
function writeJumpSecrets(apiToken, 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-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
\t\t// treats a non-empty tlsOptions as "use implicit TLS" regardless of the
\t\t// URL scheme -- pointed at the plain port, that means it opens a raw TLS
\t\t// handshake against a server expecting plaintext LDAP, which slapd just
\t\t// drops (logged as "connection lost", no BIND ever attempted). This bit
\t\t// jump-host silently: every SSH login failed with the generic
\t\t// "Permission denied" for any password, because getUser()/checkPassword()
\t\t// never even reached slapd.
\t\turl: 'ldaps://sso-manager:636',
\t\tbindDN: ${JSON.stringify(BIND_DN)},
\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)},
\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)},
\t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)},
\t\ttlsOptions: { rejectUnauthorized: false },
\t},
\tsso: {
\t\turl: 'http://sso-manager:3001',
\t\tapiToken: ${JSON.stringify(apiToken)},
\t},
\tssh: {
\t\tlistenPort: 2222,
\t\thostKeyPath: '/var/lib/jump-host/keys',
\t\tpasswordAuth: 'off',
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
\t},
\tweb: { port: 3002 },
\t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint /
\t// userinfoEndpoint use the internal docker-net address (server-to-server);
\t// authorizationEndpoint is the public SSO host (browser-facing).
\toidc: {
\t\tenabled: true,
\t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)},
\t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)},
\t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token',
\t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
\t\tclientId: ${JSON.stringify(oidc.id)},
\t\tclientSecret: ${JSON.stringify(oidc.secret)},
\t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)},
\t\tscopes: ['openid', 'profile', 'email', 'groups'],
\t\tgroupsClaim: 'groups',
\t\tusernameClaim: 'preferred_username',
\t},
\tauth: {
\t\tadminGroups: ['app_sso_admin'],
\t\tadminUsers: ['jumpadmin'],
\t\tlocalAdminPass: ${JSON.stringify(localAdminPass)},
\t},
\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } },
\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} },
};
`;
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.');
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);
// Mint (or reuse) the jump host's own OAuth client for web-UI SSO login.
const clients = await listClients(token);
let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME);
if (oidc && oidc.client_id) {
oidc = await rotateClient(token, oidc.client_id);
oidc = { id: oidc.id, secret: oidc.secret };
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-suite jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
const localAdminPass = crypto.randomBytes(16).toString('hex');
writeJumpSecrets(apiToken, oidc, localAdminPass);
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
return oidc.id;
}
(async function main() {
try {
log(`Base DN: ${BASE_DN}`);
@@ -674,7 +300,6 @@ async function provisionJumpHost(token) {
const list = await listClients(token);
// Find the proxy's client: by id if we have usable creds, else by name.
let resolvedClientId = '';
let client = null;
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
@@ -687,7 +312,6 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', EXISTING_ID);
out('CLIENT_SECRET', EXISTING_SECRET);
out('ALREADY_CONFIGURED', '1');
resolvedClientId = EXISTING_ID;
} else if (client) {
// Client exists but the file has no recoverable secret for it — rotate
// so the proxy gets a fresh secret it can actually read, then write back.
@@ -697,7 +321,6 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
} else {
// No client yet — create one and write the generated creds back.
const { id, secret } = await createClient(token);
@@ -705,44 +328,7 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
}
// 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, jumpClientId);
} catch (e) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
log('Done.');
process.exit(0);
} catch (e) {
@@ -1,26 +0,0 @@
# 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" )
+3 -3
View File
@@ -1,12 +1,12 @@
'use strict';
// Example proxy secrets for the theta-suite unified stack. Copy to
// Example proxy secrets for the theta-env 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
// file documents the shape for manual editing / reference.
//
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
// CONF_SECRETS to point at it). Never commit ./config/.
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh
// symlinks it to /app/conf/secrets.js). Never commit ./config/.
module.exports = {
oidc: {
+3 -16
View File
@@ -1,11 +1,11 @@
'use strict';
// Example SSO secrets for the theta-suite unified stack. Copy to
// Example SSO secrets for the theta-env 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.
//
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
// CONF_SECRETS to point at it). The app ignores the extra stack/bootstrap/
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh symlinks
// it to /app/conf/secrets.js). The app ignores the extra stack/bootstrap/
// serviceAccountPass keys (read by the orchestrator). Back this up off-host —
// it holds all SSO secrets. Never commit ./config/.
@@ -17,9 +17,6 @@ module.exports = {
bindPassword: 'CHANGE-ME', // slapd root + app bind password
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: internal-only hostname
// shown on /integrations for direct LDAPS binds. Empty -> derive from issuer.
// ldapsPort: 636,
},
smtp: { // optional; leave host '' to skip
host: '', port: 587, secure: false,
@@ -30,16 +27,6 @@ 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: {
+14 -157
View File
@@ -1,4 +1,4 @@
# theta-suite — unified SSO Manager + Proxy.
# theta-env — 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,
@@ -13,12 +13,11 @@
# Config + secrets live in bind-mounted ./config/ (gitignored):
# ./config/sso-secrets.js — SSO app + orchestrator config
# ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config
# Each app's entrypoint points CONF_SECRETS at its file so @simpleworkjs/conf
# (>= 1.2.0) reads it directly -- no app_* env is passed (app_* env would
# override secrets.js), and no write access to /app/conf is needed. The
# sso-manager mounts ./config read-write so the bootstrap can write the
# generated OAuth client creds back into proxy-secrets.js; the proxy mounts
# it read-only.
# Each app's entrypoint symlinks its file into /app/conf/secrets.js so
# @simpleworkjs/conf reads it. No app_* env is passed (app_* env would override
# secrets.js). The sso-manager mounts ./config read-write so the bootstrap can
# write the generated OAuth client creds back into proxy-secrets.js; the proxy
# mounts it read-only.
#
# Compose only interpolates the port defaults below — there is no .env file.
# First-run wiring (LDAP service account, first admin, OAuth client) is
@@ -30,22 +29,8 @@ services:
build:
context: ./sso-manager-node
dockerfile: Dockerfile.openldap
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C sso-manager-node rev-parse --short HEAD).
GIT_COMMIT: ${SSO_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build (NOT
# the theta42 "proxy" app). Set CFG_HTTP_PROXY in setup.env; empty by
# default, so this is a no-op unless configured.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: sso-manager
restart: unless-stopped
depends_on:
- openbao
networks: [theta-net]
ports:
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
@@ -54,32 +39,18 @@ services:
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
# in sso-secrets.js) and do NOT forward 636 to the public internet.
- "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
environment:
# 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.
# 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.
- 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:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
# can write the generated OAuth client creds into proxy-secrets.js. The
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
# entrypoint symlinks /config/sso-secrets.js -> /app/conf/secrets.js.
- ./config:/config
# Persist the LDAP database across container recreation.
- ldap-data:/var/lib/ldap
@@ -103,25 +74,12 @@ services:
build:
context: ./proxy
dockerfile: Dockerfile
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C proxy rev-parse --short HEAD).
GIT_COMMIT: ${PROXY_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: proxy
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
@@ -131,28 +89,14 @@ 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 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).
# 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.
- 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:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
# only reads it; the sso-manager bootstrap writes the OAuth creds. The
# 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.
# entrypoint symlinks /config/proxy-secrets.js -> /app/conf/secrets.js.
- ./config:/config:ro
# Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local
# users, AND the auto-ssl Let's Encrypt certs survive container recreation.
@@ -170,90 +114,6 @@ services:
retries: 3
start_period: 30s
# 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:
build:
context: ./jump-host
dockerfile: Dockerfile
args:
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: jump-host
restart: unless-stopped
networks: [theta-net]
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:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
- jump-data:/var/lib/jump-host # generated host keys persist here
- jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here
# A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for
# testing jump-host's actual key-injection -> upstream-connect flow --
# a container with a manually-dropped public key in authorized_keys never
# exercises the LDAP-key-serving path a real production host does. Built
# from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars
# for setup notes. 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
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
@@ -264,7 +124,4 @@ volumes:
sso-data:
proxy-data:
proxy-cache:
proxy-logs:
jump-data:
jump-redis-data:
openbao-data:
proxy-logs:
+8 -43
View File
@@ -1,44 +1,9 @@
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-suite"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
title: theta-env
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses
theme: jekyll-theme-cayman
show_downloads: true
github:
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
page: /
icon: fa-house
- title: Quickstart
page: /quickstart.html
icon: fa-rocket
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Secrets
page: /secrets.html
icon: fa-key
- title: Standalone
page: /standalone.html
icon: fa-puzzle-piece
- title: Changelog
url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
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
-82
View File
@@ -1,82 +0,0 @@
<!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>
+75 -164
View File
@@ -1,131 +1,76 @@
---
layout: default
title: Architecture
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-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.
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.
---
## Components
## The three repos
| Repo / image | Role |
| Repo | 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/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. |
| `theta42/theta-env` (this repo) | Composes the two on one Docker network + automates first-run wiring. |
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 projects are pinned as **git submodules**. `git clone --recursive`
fetches all three in one step; `git submodule update --remote` bumps them.
---
## The stack
## The two containers
```
──────────────────────────────────────────────────────────┐
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`).
┌──────────────────────────────────────────────┐
│ your browser / apps / legacy 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, …)
└──────────────────────────────┘
```
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.
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.
### What's exposed, what's not
| 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/*`; 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).
| 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) |
---
@@ -136,14 +81,7 @@ 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).
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:
(not from env):
1. **Build + start sso-manager**, wait for `/health`.
2. **LDAP service account**`ldapadd` `cn=ldapclient,ou=people,<base>` (an
@@ -158,53 +96,33 @@ policies and the `sso-broker` token role, mints the per-app scoped tokens into
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`
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
than the proxy's own HTTP API, since no authenticated session exists yet at
this point in the run. The proxy routes every hostname purely off a Host
record (`ops/nginx_conf/proxy.conf` has no default/self route), so without
this step neither URL resolves to anything. `<SSO_HOST>` targets
`sso-manager:3001` (the Docker service), `<PROXY_HOST>` targets
`127.0.0.1:3000` (the proxy's own management app, same container). Both
are created with `sso_enabled: false` — each app already gates its own
login, and SSO-gating the SSO's own login page would be circular. Skips a
host that already exists, so re-running `setup.sh` is a no-op here.
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 symlinks
`./config/proxy-secrets.js` to `/app/conf/secrets.js`, so `@simpleworkjs/conf`
(≥1.1.0) reads the OAuth creds + LDAP bind creds from the file.
`setup.sh` then prints the first-admin login + the public URLs.
### How config reaches the apps
### How config reaches the apps (no `.env`)
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:
All config and secrets live in `./config/` (gitignored, bind-mounted). Each
entrypoint symlinks its file to `/app/conf/secrets.js` early, before the app
starts:
```
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)
./config/sso-secrets.js -> sso-manager:/app/conf/secrets.js (./config RW)
./config/proxy-secrets.js -> proxy:/app/conf/secrets.js (./config RO)
```
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_*
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.
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → conf/secrets.js → app_*
env`, where **env beats `secrets.js`**. So compose passes **no `app_*` env vars**
(only `NODE_ENV`, `NODE_PORT`) — that makes `secrets.js` authoritative. The SSO
entrypoint reads the few values it needs at startup (LDAP base DN, admin
password, JWT secret, cert CN) from `secrets.js` via an in-container `node` call.
### Why not `require` the SSO's internal models?
@@ -219,15 +137,12 @@ end-to-end.
## Idempotency
Re-running `./setup.sh` converges to `./config/` + OpenBao:
Re-running `./setup.sh` converges to `./config/`:
- 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 (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).
created or rotated otherwise, and the new creds written back.
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
compose down`, or after restoring from backup.
@@ -236,18 +151,14 @@ compose down`, or after restoring from backup.
## Backups and restore
`./setup.sh` auto-snapshots `./config/` + LDAP + all the Redis instances to
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
`./backups/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
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:
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:
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
-116
View File
@@ -1,116 +0,0 @@
/* 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
@@ -1,51 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

+97 -69
View File
@@ -1,91 +1,119 @@
---
layout: default
title: Home
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. Wires together a self-hosted identity provider and a reverse proxy with one setup.sh.
---
# theta-suite
# theta-env
The whole theta42 identity, access, and secrets stack in one repo, brought up
with a single command — for home labs and small businesses.
A single repo that runs the whole theta42 identity + access stack —
[SSO Manager](https://github.com/theta42/sso-manager-node) (OIDC provider + LDAP)
and the [theta42/proxy](https://github.com/theta42/proxy) (OIDC-protected reverse
proxy) — together, with **one command**, for home labs and small businesses.
It composes four applications around a shared secrets store:
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
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`.
It exists for people whose needs are met by these two projects and who want to
run them "very simply." Each project still works **standalone**; this repo just
wires them together and automates the first-run glue.
## Screenshots
---
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
## Quick start
<a href="images/sso-dashboard.png" target="_blank"><img src="images/sso-dashboard.png" alt="SSO Manager dashboard" width="49%"></a>
<a href="images/proxy-hosts.png" target="_blank"><img src="images/proxy-hosts.png" alt="Proxy host list" width="49%"></a>
<a href="images/jump-dashboard.png" target="_blank"><img src="images/jump-dashboard.png" alt="Jump Host dashboard" width="49%"></a>
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
```
*(click either screenshot to view full size)*
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
time to converge the stack to `./config/`.
## Why this over running them separately
See the [Quickstart Guide](quickstart.html) for a walkthrough of `./config/` and
what `setup.sh` does, [Architecture](architecture.html) for how the pieces fit
together, and [Standalone](standalone.html) for running each project on its own.
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
snapshots state before every rebuild.
---
## What you get
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
and OAuth clients.
- **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
LDAP-native apps authenticate against the same directory.
- **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.
- **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.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
- **SSO Manager** at `https://<SSO_HOST>` — log in as your first admin to manage
users, groups, and OAuth clients. Fronted by the proxy under TLS.
- **Proxy** at `https://<PROXY_HOST>` — add the Host records you want to protect
with OIDC login.
- **LDAPS** at `ldaps://<host>:636` — legacy apps can bind directly (admin or
the read-only `cn=ldapclient` service account the bootstrap creates).
- **API tokens** — both apps let any logged-in user mint self-service personal
access tokens (`Authorization: Bearer sso_…` / `prx_…`) to drive the management
API from scripts/CI without a browser session. A token authenticates as its
creator (carrying their permissions); mint/rotate/revoke under **API Tokens**
in each UI. See each submodule's DEPLOYMENT for the details.
## Get it
---
```bash
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
## The `./config/` values you must set
All config and secrets live in `./config/sso-secrets.js` +
`./config/proxy-secrets.js` (gitignored), generated by the first `./setup.sh`.
There is **no `.env`**. Set at least these in `./config/sso-secrets.js`:
| Key (in `sso-secrets.js`) | What it is |
|-----|------------|
| `stack.ldapBaseDn` | Directory base, e.g. `dc=lab,dc=local`. |
| `ldap.bindPassword` | LDAP root password (generated). **Back it up.** |
| `oauth.jwtSecret` | Signs the SSO's tokens (generated). **Back it up.** |
| `stack.ssoHost` | Public hostname the proxy serves the SSO UI at. |
| `stack.proxyHost` | Public hostname the proxy serves its own mgmt UI at. |
| `bootstrap.adminUid` / `bootstrap.adminPass` | Your first admin login. |
See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy
OIDC/LDAP/auth, …).
---
## Architecture
```
┌──────────────────────────────────────────────┐
│ your browser / apps │
└───────────────┬──────────────────────────────┘
│ https
┌─────────▼─────────┐
│ proxy │ OpenResty :80/:443/:4443
│ (OIDC + LDAP) │ mgmt app :3000 (localhost)
└─────────┬─────────┘ bundled redis
┌─────────────┼──────────────────────┐
│ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo
▼ ▼ │
┌──────────────────────────┐ │
│ sso-manager │◄────────────────┘
│ OIDC provider + OpenLDAP │ bundled redis
│ web UI :3001 (localhost) │
│ ldaps :636 (LAN clients) │
└───────────────────────────┘
```
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-suite)**.
The proxy is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). See [Architecture](architecture.html) for the full
diagram + the first-run bootstrap flow.
## Related projects
---
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it.
- **[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.
## Documentation
- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `setup.sh`.
- [Architecture](architecture.html) — the 3-repo + submodule + 2-container
design, and how the bootstrap wires the proxy into a fresh SSO.
- [Standalone](standalone.html) — running SSO Manager or the proxy on its own.
---
## Community
- [GitHub Repository](https://github.com/theta42/theta-env)
- [Issue Tracker](https://github.com/theta42/theta-env/issues)
---
## License
MIT License — see the repository for details.
+11 -21
View File
@@ -1,7 +1,6 @@
---
layout: default
title: Quickstart
description: Step-by-step first run for theta-suite — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
---
# Quickstart Guide
@@ -26,8 +25,8 @@ description: Step-by-step first run for theta-suite — prerequisites, setup.env
## 1. Clone
```bash
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
```
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
@@ -43,25 +42,20 @@ git submodule update --init --recursive
```bash
cp setup.env.example setup.env
$EDITOR setup.env # set CFG_DOMAIN to your domain
$EDITOR setup.env # set CFG_BASE_DN to your domain, as a base DN
```
Your domain is entered **once**, as a plain DNS domain. The SSO/proxy
hostnames default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN
is built from it (any number of labels works — a domain like
`myhost.duckdns.org` becomes `dc=myhost,dc=duckdns,dc=org`), so for most
setups `CFG_DOMAIN` is the only value you set:
Your domain is entered **once**, as the LDAP base DN. The SSO/proxy hostnames
default to `sso.<domain>` / `proxy.<domain>`, derived from it, so for most setups
`CFG_BASE_DN` is the only value you set:
| `setup.env` key | Example | Notes |
|-----|---------|-------|
| `CFG_DOMAIN` | `lab.local` | your domain — **required** |
| `CFG_BASE_DN` | `dc=lab,dc=local` | your directory base — **required** |
| `CFG_SSO_HOST` | `sso.lab.local` | optional, defaults to `sso.<domain>` |
| `CFG_PROXY_HOST` | `proxy.lab.local` | optional, defaults to `proxy.<domain>` |
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
| `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
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets
@@ -95,10 +89,7 @@ What happens:
service account, your first admin, and the proxy's OAuth client, and writes
the generated client id + secret into `./config/proxy-secrets.js`.
4. Builds + starts **proxy**, waits for `/health`.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy —
every hostname the proxy serves, including its own UI and the SSO's,
needs one of these or it 404s. Idempotent.
6. Prints your first-admin login + the public URLs.
5. Prints your first-admin login + the public URLs.
The first run builds two Docker images (a few minutes). Subsequent runs are
fast.
@@ -143,10 +134,9 @@ already holds its creds).
---
## Direct LDAP for LDAP-native clients and Linux hosts
## Direct LDAP for legacy apps
LDAP-native apps and Linux hosts (PAM/SSSD, sudo, SSH keys) bind LDAP directly
over LDAPS:
Legacy apps bind LDAP directly over LDAPS:
```bash
ldapsearch -x -H ldaps://<host>:636 \
@@ -165,7 +155,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-suite#backups-and-restore). Quick LDAP
[README](https://github.com/theta42/theta-env#backups-and-restore). Quick LDAP
backup:
```bash
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/theta-suite/sitemap.xml
-195
View File
@@ -1,195 +0,0 @@
---
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/*`; `update` on `auth/token/create/sso-broker`; `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 role `sso-broker`**`allowed_policies=sso-admin`,
`allowed_policies_glob=user-*,app-*`, orphan, renewable, `token_period=24h`.
The SSO mints per-user, per-admin, and per-app tokens *through* this role at
runtime, so it never needs the root token to issue scoped access.
The three per-app tokens (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`) are minted orphan + renewable and stored in `./.env` by
`setup.sh`. They use OpenBao's default service-token TTL; if one expires,
re-run `./setup.sh` and the `ensure_token` helper re-mints it (the old one
expires on its own). Automated renewal is a planned follow-up, not yet built.
## 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: '...' });
```
## 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.
+18 -28
View File
@@ -1,23 +1,15 @@
---
layout: default
title: Standalone
description: Running a component individually, without theta-suite's orchestration — an advanced path; the integrated stack is the supported one.
---
# Running a component individually
# Running each project standalone
[← Back to Home](index.html)
> **The integrated stack is the supported path.** `./setup.sh` wiring all four
> components together around a shared OpenBao secrets store is what's tested and
> released. The steps below are for the advanced case where you want to run one
> component on its own — a separate host, a different network, or without the
> orchestrator. Running standalone means managing secrets from the
> `config/*-secrets.js` file only (no shared OpenBao) and doing the OIDC/LDAP
> wiring by hand.
The submodules in this repo are normal clones; you can also clone them directly
from GitHub. Each component builds and runs on its own.
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.
---
@@ -32,18 +24,18 @@ 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
The entrypoint symlinks `config/sso-secrets.js` to `nodejs/conf/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.
**no `app_*` env** — env beats `secrets.js`, 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:
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal
@@ -69,11 +61,11 @@ 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 +
The entrypoint symlinks `config/proxy-secrets.js` to `nodejs/conf/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.
`auth` (admin groups/users). Pass **no `app_*` env** — env beats `secrets.js`,
so `app_*` would silently override your file.
- Proxy (public, auto-SSL): `https://<host>/`
- Mgmt UI / API: `http://127.0.0.1:3000/`
@@ -121,12 +113,10 @@ See the proxy
---
## Wiring components together by hand
## Mixing and matching
If you have a specific reason to run the components on separate hosts instead
of through `./setup.sh` (and accept that you lose the shared OpenBao secrets
store), the four wiring steps are documented in both projects' deployment
guides:
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.
@@ -138,8 +128,8 @@ guides:
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`.
`./setup.sh` exists to do all of this for you — and to add the OpenBao secrets
store, jump host, and ldap-client on top. Unless you need the components on
separate hosts, prefer the integrated stack.
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 jump-host deleted from db3333e26d
Submodule ldap-client deleted from 31d8fa1229
-1
View File
@@ -1 +0,0 @@
https://github.com/theta42/theta-suite/pull/75
+1 -1
Submodule proxy updated: 4aa994121a...3df7d8c5cb
+12 -70
View File
@@ -1,5 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# setup.env — first-run setup for the theta-suite stack.
# setup.env — first-run setup for the theta-env 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
@@ -7,71 +7,29 @@
# (edit them directly; setup.env is ignored on later runs).
#
# cp setup.env.example setup.env
# $EDITOR setup.env # set CFG_DOMAIN below to your domain
# $EDITOR setup.env # set CFG_BASE_DN below to your domain
# ./setup.sh # generates ./config/ and builds the stack
#
# Copying this file to setup.env (gitignored) keeps your domain out of git.
# ─────────────────────────────────────────────────────────────────────────────
# Your domain. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED. Everything else
# derives from it: the SSO/proxy hostnames default to sso.<domain> /
# proxy.<domain>, and the LDAP base DN is built from it (example.com becomes
# dc=example,dc=com; a 3-label domain like myhost.duckdns.org becomes
# dc=myhost,dc=duckdns,dc=org — any number of labels works). Required —
# setup.sh refuses to run without it.
CFG_DOMAIN=example.com
# Site name for the SSO directory — the root node this stack registers itself
# under on the Directory page, and the default "Location (Site)" that Linux
# hosts joined via ldap-client attach to (parent slug: site_<name>).
# Optional — defaults to "local".
#CFG_SITE_NAME=local
# Your domain, as an LDAP base DN. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED.
# Everything else derives from it: the SSO/proxy hostnames default to
# sso.<domain> / proxy.<domain>, and the LDAP DNs are cn=admin,<dn>,
# ou=people,<dn>, ou=groups,<dn>. Required — setup.sh refuses to run without it.
CFG_BASE_DN=dc=example,dc=com
# Public hostnames. Optional — default to sso.<domain> / proxy.<domain> derived
# from CFG_DOMAIN above. Uncomment and set only if your hostnames differ
# from CFG_BASE_DN above. Uncomment and set only if your hostnames differ
# (e.g. a different subdomain, or the domain isn't the bare apex):
#CFG_SSO_HOST=sso.example.com
#CFG_PROXY_HOST=proxy.example.com
# ── 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)
# Advanced: override the derived LDAP base DN directly (e.g. to namespace
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
#CFG_BASE_DN=dc=example,dc=com
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
# For an isolated/offline/corporate-network test host that only reaches the
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
# Wired into every service's docker build (npm/apt) AND its running container
# (SMTP, ACME/Let's Encrypt, DNS provider calls, the jump-host directory API
# client). Leave unset to disable (the default); CFG_HTTPS_PROXY falls back to
# CFG_HTTP_PROXY if unset, and CFG_NO_PROXY defaults to covering the stack's
# own internal service names so container-to-container traffic never goes
# through the proxy.
#CFG_HTTP_PROXY=http://proxy.example.com:3128
#CFG_HTTPS_PROXY=http://proxy.example.com:3128
#CFG_NO_PROXY=localhost,127.0.0.1,sso-manager,proxy,jump-host
# Optional — sensible defaults if left blank:
#CFG_ORG=SSO Manager # app display name + outbound email org
#CFG_ADMIN_UID=admin # initial SSO admin username
#CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost>
#CFG_LDAP_CERT_CN= # LDAP TLS cert CN; empty -> defaults to the domain
#
# Hostname advertised on the SSO /integrations page for direct LDAPS binds.
# Leave blank to derive it from the public SSO host (same as oauth.issuer).
# Recommended: set an internal-only name like 'ldap.internal.example.com' or
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
#CFG_LDAPS_HOST=
# Optional SMTP (outbound email from the SSO app). Leave blank to disable:
#CFG_SMTP_HOST=smtp.example.com
@@ -81,23 +39,7 @@ CFG_DOMAIN=example.com
#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)
# into ./config/sso-secrets.js + ./config/proxy-secrets.js on first run.
# Change them later by editing those files directly (the proxy's local admin
# password is the exception — see ./config/proxy-secrets.js's auth.localAdminPass
# comment for how to actually change it after the account exists). Do NOT set
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
# CFG_PROXY_ADMIN_PASS here.
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
# If deploying this stack across multiple physical sites to provide local HA
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
# This requires assigning a unique ID to each site and listing the LDAPS URLs
# of all OTHER sites in the cluster.
#
# Each site MUST have a unique LDAP_SERVER_ID (e.g. 1, 2, 3).
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
# Example for Site 1:
#LDAP_SERVER_ID=1
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
# The LDAP admin password, JWT secret, admin password, and LDAP service-account
# password are GENERATED (random) into ./config/sso-secrets.js on first run.
# Change them later by editing ./config/sso-secrets.js directly. Do NOT set
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS here.
+37 -528
View File
@@ -1,38 +1,28 @@
#!/usr/bin/env bash
#
# theta-suite setup — one-command bring-up of the unified SSO Manager + Proxy stack.
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
#
# git clone --recursive <theta-suite> && cd theta-suite
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once)
# git clone --recursive <theta-env> && cd theta-env
# cp setup.env.example setup.env # set CFG_BASE_DN to your domain (once)
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
#
# Idempotent: safe to re-run. It pulls its own latest version, updates the two
# submodules, manages config in a bind-mounted ./config/ directory
# (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots state
# before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# Idempotent: safe to re-run. It manages config in a bind-mounted ./config/
# directory (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots
# state before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# converges the LDAP service account / first admin / OAuth client to the ./config
# values and writes the generated OAuth client creds into proxy-secrets.js),
# then starts the proxy and registers the SSO's + proxy's own hostnames as
# Host records in it (otherwise the proxy has no route for either). A single
# `./setup.sh` run is enough to bring an existing deployment fully up to date —
# no manual `git pull` needed first.
# then starts the proxy.
#
# What it does, in order:
# 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
# SKIP_SELF_UPDATE=1.
# 1. Update the git submodules to the latest of their tracked remote branch
# (so each run builds the newest sso-manager-node + proxy). Skip with
# SKIP_SUBMODULE_UPDATE=1.
# 2. ensure_config: create ./config/sso-secrets.js + proxy-secrets.js if
# missing. On a fresh clone the domain/hosts are read from ./setup.env
# (the one place the domain is entered, as a plain DNS domain — the LDAP
# base DN is derived from it) and both files are generated with that
# domain filled in everywhere + random secrets, then the run proceeds to
# build (no edit-and-re-run step). On
# (the one place the domain is entered, as the LDAP base DN) and both
# files are generated with that domain filled in everywhere + random
# secrets, then the run proceeds to build (no edit-and-re-run step). On
# an existing deployment with .env/proxy.env, the secrets are migrated
# (preserved) into ./config. If ./config already exists it is left
# untouched (the operator owns it; setup.env is ignored).
@@ -43,17 +33,9 @@
# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js
# -> creates/updates the LDAP service account, first admin, OAuth client;
# writes the OAuth client creds into ./config/proxy-secrets.js; prints
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. Also seeds
# the SSO directory with the stack's own resources (site -> host ->
# SSO Manager + Proxy services, with the proxy's OAuth client linked
# under its service) so the Directory page is populated out of the
# box. Idempotent — existing slugs are operator-owned and left alone.
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout.
# 6. docker compose up -d --build proxy; wait for /health.
# 7. Register <SSO_HOST> and <PROXY_HOST> as Host records in the proxy (via
# `docker compose exec proxy node`, calling the proxy's Host model
# directly) so the proxy actually routes those hostnames somewhere —
# nothing else creates them. Idempotent; skips a host that already exists.
# 8. Print the first-admin login + the public URLs.
# 7. Print the first-admin login + the public URLs.
#
# Requires: git, docker + docker compose (v1 standalone or v2 plugin).
@@ -88,23 +70,6 @@ rand_hex() {
fi
}
# Upsert KEY=VALUE into ./.env, which `docker compose` auto-loads for every
# future invocation in this directory. Used to persist the *_GIT_COMMIT build
# args (see SSO_GIT_COMMIT/PROXY_GIT_COMMIT/JUMP_GIT_COMMIT below) so that an
# ad-hoc `docker compose up --build <service>` run later, OUTSIDE this script,
# still resolves the right commit instead of silently baking "unknown" (the
# submodule .git pointer file can't be resolved from inside the build
# context, so the value must come from the host via this file or the export).
env_upsert() {
local key="$1" val="$2" file=./.env
touch "$file"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
else
printf '%s=%s\n' "$key" "$val" >> "$file"
fi
}
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
@@ -141,108 +106,15 @@ parse_kv_file() {
done < "$file"
}
# ── 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
# so the rest of THIS run uses the freshly-pulled script rather than the copy
# already read into memory. Never blocks the run: skips silently if this
# isn't a git checkout, is on a detached HEAD, or has no upstream configured;
# warns (but continues on the current checkout) if the pull fails for any
# other reason (offline, local changes that prevent a fast-forward). Skip
# entirely with SKIP_SELF_UPDATE=1.
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_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
BEFORE_REV="$(git rev-parse HEAD)"
BEFORE_VER="$(git describe --tags "$BEFORE_REV" 2>/dev/null || echo "${BEFORE_REV:0:12}")"
if git pull --ff-only -q; then
AFTER_REV="$(git rev-parse HEAD)"
if [[ "$BEFORE_REV" != "$AFTER_REV" ]]; then
AFTER_VER="$(git describe --tags "$AFTER_REV" 2>/dev/null || echo "${AFTER_REV:0:12}")"
info "Updated theta-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-suite to the latest upstream (offline, or local changes) — continuing with the current checkout."
fi
fi
# ── 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
export CFG_JUMP_HOST
# ── Optional outbound HTTP(S) proxy for docker build + the running containers ─
# CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the
# environment) — NOT the theta42 "proxy" app; this is an upstream HTTP proxy
# for reaching the internet (npm/apt during image builds, and SMTP/ACME/DNS
# provider calls at runtime), useful on isolated/offline/corporate-network
# test hosts. Off by default. docker-compose.yml passes these through as both
# build args (Docker also recognizes them as predefined build ARGs) and
# container environment on every service, so one setup.env entry covers the
# whole stack.
export CFG_HTTP_PROXY="${CFG_HTTP_PROXY:-}"
export CFG_HTTPS_PROXY="${CFG_HTTPS_PROXY:-${CFG_HTTP_PROXY:-}}"
export CFG_NO_PROXY="${CFG_NO_PROXY:-localhost,127.0.0.1,sso-manager,proxy,jump-host}"
if [[ -n "$CFG_HTTP_PROXY" ]]; then
info "Using HTTP proxy for docker build + containers: $CFG_HTTP_PROXY"
fi
# ── 1. Update submodules to their latest release tag, verify build contexts ───
# Submodules track release tags (vX.Y.Z), not the tip of master -- so
# "update" means "move to the newest tag", not "move to the newest commit".
# `git submodule update --init --recursive` (no --remote) only clones a
# missing submodule at its currently-pinned commit; it never advances it on
# its own, so the per-submodule tag resolution below is what actually moves
# proxy/sso-manager-node forward.
# ── 1. Update submodules to latest, verify build contexts ─────────────────────
if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
if ! command -v git >/dev/null 2>&1; then
die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits."
fi
if ! git submodule update --init --recursive 2>&1; then
die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
info "Updating submodules to latest (sso-manager-node, proxy)..."
if ! git submodule update --init --remote --recursive 2>&1; then
warn "git submodule update failed (offline?) — continuing with the currently checked-out code."
fi
# 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
before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
# Prefer the exact tag the submodule is currently pinned to; fall back
# to a short commit hash if it's on an untagged commit (shouldn't
# normally happen -- this repo only ever pins tagged releases).
before_tag="$(git -C "$sm" describe --tags --exact-match "$before_rev" 2>/dev/null || echo "${before_rev:0:12}")"
if ! git -C "$sm" fetch --tags -q 2>&1; then
warn " ${sm}: could not fetch tags (offline?) — staying on ${before_tag}."
continue
fi
latest_tag="$(git -C "$sm" tag --list 'v*' --sort=-v:refname | head -n1)"
if [[ -z "$latest_tag" ]]; then
warn " ${sm}: no vX.Y.Z release tags found — staying on ${before_tag}."
continue
fi
if ! git -C "$sm" checkout -q "$latest_tag" 2>&1; then
warn " ${sm}: could not check out ${latest_tag} — staying on ${before_tag}."
continue
fi
after_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
if [[ "$before_rev" != "$after_rev" ]]; then
info " ${sm}: updated ${before_tag} -> ${latest_tag}"
else
info " ${sm}: already up to date (${latest_tag})"
fi
done
else
info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)."
fi
@@ -253,22 +125,11 @@ fi
|| die "proxy/Dockerfile missing. Run: git submodule update --init --recursive"
# ── 2. ensure_config ──────────────────────────────────────────────────────────
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar). Only used to
# read domain back out of a base DN set directly (advanced override, or an
# old setup.env / migrated .env) — the normal path is dn_from_domain below.
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar).
domain_from_dn() {
echo "$1" | sed 's/^dc=//; s/,dc=/./g'
}
# Derive an LDAP base DN from a DNS domain (foo.bar -> dc=foo,dc=bar). This is
# the normal path: operators enter a plain domain in setup.env (CFG_DOMAIN),
# and the base DN is built from it, however many labels it has (a DuckDNS
# domain like foo.duckdns.org becomes dc=foo,dc=duckdns,dc=org — LDAP doesn't
# care how many dc= components there are).
dn_from_domain() {
echo "dc=$1" | sed 's/\./,dc=/g'
}
# Write ./config/sso-secrets.js from the CFG_* shell vars.
write_sso_secrets() {
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
@@ -276,7 +137,7 @@ write_sso_secrets() {
cat > "$CONFIG_DIR/sso-secrets.js" <<SSOEOF
'use strict';
// Generated by setup.sh. Edit freely; re-run ./setup.sh to apply.
// The SSO app reads this via @simpleworkjs/conf (CONF_SECRETS env var).
// The SSO app reads this via @simpleworkjs/conf (symlinked to conf/secrets.js).
// The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by
// the orchestrator). Back this file up off-host — it holds all SSO secrets.
@@ -288,8 +149,6 @@ module.exports = {
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
userBase: $(js_str "ou=people,${dn}"),
groupBase: $(js_str "ou=groups,${dn}"),
ldapsHost: $(js_str "${CFG_LDAPS_HOST:-}"),
ldapsPort: 636,
},
smtp: {
host: $(js_str "${CFG_SMTP_HOST:-}"),
@@ -304,22 +163,11 @@ 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: {
ldapBaseDn: $(js_str "$dn"),
ldapDomain: $(js_str "$domain"),
siteName: $(js_str "${CFG_SITE_NAME:-local}"),
ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"),
ssoHost: $(js_str "$CFG_SSO_HOST"),
proxyHost: $(js_str "$CFG_PROXY_HOST"),
@@ -340,8 +188,8 @@ write_proxy_secrets() {
local dn="$CFG_BASE_DN"
cat > "$CONFIG_DIR/proxy-secrets.js" <<PROXYEOF
'use strict';
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (CONF_SECRETS
// env var). clientId/clientSecret are filled in by the bootstrap
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (symlinked
// to conf/secrets.js). clientId/clientSecret are filled in by the bootstrap
// (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal
// serviceAccountPass in sso-secrets.js (the proxy binds as that account).
@@ -373,10 +221,6 @@ module.exports = {
adminGroups: ['app_sso_admin'],
adminUsers: ['proxyadmin2'],
groupRoleMap: {},
// Initial password for the local anti-lockout admin (proxyadmin2) —
// only read by the proxy the first time that account is created;
// changing it here later has no effect on an already-created account.
localAdminPass: $(js_str "$CFG_PROXY_ADMIN_PASS"),
},
stack: {
ssoHost: $(js_str "$CFG_SSO_HOST"),
@@ -387,32 +231,14 @@ 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
fi
# First run: read the domain/hosts from ./setup.env — the ONE place the
# domain is entered (e.g. 718it.biz), as a plain DNS domain; the LDAP base
# DN is derived from it (dc=718it,dc=biz). Hostnames default to
# sso.<domain> / proxy.<domain>, also derived from it. setup.env is
# domain is entered, as the LDAP base DN (e.g. dc=718it,dc=biz). Hostnames
# default to sso.<domain> / proxy.<domain>, derived from it. setup.env is
# used ONLY on first run; once ./config/*.js exist they are operator-owned
# and setup.env is ignored. Falls back to legacy .env/proxy.env migration
# below for existing deployments.
@@ -427,21 +253,18 @@ BAOEOF
# derivation block further down (no example.com placeholders here).
CFG_BASE_DN="${CFG_BASE_DN:-}"
CFG_DOMAIN="${CFG_DOMAIN:-}"
CFG_SITE_NAME="${CFG_SITE_NAME:-}"
CFG_ORG="${CFG_ORG:-}"
CFG_SSO_HOST="${CFG_SSO_HOST:-}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}"
CFG_JWT_SECRET="${CFG_JWT_SECRET:-}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
# ── One-time migration from .env / proxy.env (existing deployments) ──
# Preserve the operator's existing secrets so the running deployment keeps
@@ -463,8 +286,6 @@ BAOEOF
CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}"
CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}"
CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}"
# .env has no legacy LDAPS_HOST key; this stays as set in setup.env/env.
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_SMTP_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}"
CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}"
CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}"
@@ -483,23 +304,17 @@ BAOEOF
migrated=1
fi
# Derive everything from the domain — the one value operators enter. No
# example.com defaults: a blank domain means first-run setup hasn't been
# done yet. CFG_BASE_DN can still be set directly (setup.env or a migrated
# .env) to override the derived DN or to read the domain back out of an
# old-style DN-first setup.env; if not, it's built from CFG_DOMAIN.
CFG_DOMAIN="${CFG_DOMAIN:-$([[ -n "$CFG_BASE_DN" ]] && domain_from_dn "$CFG_BASE_DN" || true)}"
[[ -n "$CFG_DOMAIN" ]] \
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
# Derive everything from the base DN — the one domain value. No example.com
# defaults: a blank base DN means first-run setup hasn't been done yet.
[[ -n "$CFG_BASE_DN" ]] \
|| die "First run: 'cp setup.env.example setup.env', set CFG_BASE_DN to your domain (e.g. dc=718it,dc=biz), then re-run ./setup.sh"
CFG_DOMAIN="${CFG_DOMAIN:-$(domain_from_dn "$CFG_BASE_DN")}"
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
CFG_ORG="${CFG_ORG:-SSO Manager}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
# Random secrets (generated fresh unless sourced/migrated above). These do
@@ -508,12 +323,10 @@ BAOEOF
CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}"
CFG_SVC_PASS="${CFG_SVC_PASS:-$(rand_hex 16)}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-$(rand_hex 16)}"
mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
write_sso_secrets
write_proxy_secrets
chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js"
if [[ "$migrated" == "1" ]]; then
@@ -565,7 +378,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-suite stack (project "theta-suite") and the standalone submodule stack
# theta-env stack (project "theta-env") 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.
@@ -651,7 +464,7 @@ backup_before_rebuild() {
# Only prune real backup dirs — skip symlinks (a stray symlink could
# point rm at an arbitrary tree) and non-dir entries.
[[ -d "$BACKUP_DIR/$old" && ! -L "$BACKUP_DIR/$old" ]] || continue
rm -rf "${BACKUP_DIR:?}/$old" || true
rm -rf "$BACKUP_DIR/$old" || true
removed=$((removed + 1))
done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | sort -r | tail -n +$((keep + 1)))
[[ "$removed" -gt 0 ]] && info " pruned $removed old backup(s) (keeping $keep)."
@@ -659,175 +472,7 @@ backup_before_rebuild() {
}
backup_before_rebuild
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
info "Starting openbao..."
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
"${COMPOSE[@]}" up -d openbao
info "Waiting for openbao to be reachable..."
for i in $(seq 1 30); do
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then
info "openbao is reachable."; break
fi
if (( i == 30 )); then die "openbao did not become reachable in 60s. Check: ${COMPOSE[*]} logs openbao"; fi
sleep 2
done
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true' || true; then
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if ! echo "$status_json" | grep -q '"initialized": true'; then
info "Initializing openbao for the first time..."
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
chmod 600 "$CONFIG_DIR/bao-init.json"
info "Openbao initialized. Keys saved to $CONFIG_DIR/bao-init.json"
fi
fi
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
if echo "$status_json" | grep -q '"sealed": true'; then
info "Unsealing openbao..."
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
fi
export VAULT_TOKEN
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" | cut -d'"' -f4)
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
info "Enabling kv-v2 secrets engine at secret/..."
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets enable -path=secret kv-v2 >/dev/null
fi
# ── 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"
if bao_run policy read "$name" >/dev/null 2>&1; then
info " policy ${name} already exists — keeping."
else
info " writing policy ${name}..."
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
fi
}
# Read KEY= from ./.env (empty if absent) — reuse a previously minted token
# instead of minting a fresh one on every setup.sh run.
env_get() {
local key="$1" file=./.env
[[ -f "$file" ]] || return 0
# `|| true` is load-bearing: under `set -euo pipefail`, a no-match `grep`
# exits 1 and (pipefail) makes the whole pipeline return 1. Callers do
# `existing="$(env_get ...)"` as a bare assignment — a non-zero return there
# trips `set -e` and silently kills the whole script (this is exactly what
# aborted a fresh install right after "Minting per-app OpenBao tokens": the
# root VAULT_TOKEN env_upsert had already created .env, but the app-token
# keys were absent, so the first env_get returned 1). "Key absent" is the
# normal path here, so always return 0 with empty output.
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true
}
# Mint an orphan, renewable token for `policy` and persist it to .env as `key`,
# OR reuse the token already in .env if it is still valid (re-mint on expiry).
ensure_token() {
local key="$1" policy="$2" existing tok
existing="$(env_get "$key")"
if [[ -n "$existing" ]] && docker exec -e BAO_TOKEN="$existing" openbao bao token lookup >/dev/null 2>&1; then
info " ${key} already minted + valid — keeping."
return 0
fi
info " minting ${key} (policy=${policy})..."
tok="$(bao_run token create -policy="$policy" -orphan=true -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.
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/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 "auth/token/create/sso-broker" { 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/.
ensure_policy sso-admin <<'HCL'
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
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/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
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 role: sso-broker (mints user-*/app-*/sso-admin tokens, 24h period)"
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env"
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
# pointer file (not a real repo) -- the image can't resolve its own commit
# hash from inside the Docker build context. Resolve it on the host (where
# the submodule DOES resolve correctly) and pass it in as a build arg; see
# docker-compose.yml and sso-manager-node's Dockerfile.openldap.
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
export SSO_GIT_COMMIT
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
@@ -843,29 +488,18 @@ for i in $(seq 1 60); do
sleep 2
done
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() {
"${COMPOSE[@]}" exec -T sso-manager node -e '
const c = require("/config/sso-secrets.js");
let p = {};
try { p = require("/config/proxy-secrets.js"); } catch (_) {}
const o = {
SSO_HOST: (c.stack && c.stack.ssoHost) || "",
PROXY_HOST: (c.stack && c.stack.proxyHost) || "",
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
ORG_NAME: c.name || "",
ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "",
ADMIN_PASS: (c.bootstrap && c.bootstrap.adminPass) || "",
};
for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k]));
' 2>/dev/null
@@ -875,6 +509,7 @@ cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
SSO_HOST="$(cfgval SSO_HOST)"
PROXY_HOST="$(cfgval PROXY_HOST)"
ADMIN_UID="$(cfgval ADMIN_UID)"
ADMIN_PASS="$(cfgval ADMIN_PASS)"
info "Stack config:"
info " SSO host: https://${SSO_HOST}"
@@ -883,38 +518,14 @@ 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 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.
# generated OAuth client creds back into /config/proxy-secrets.js. No -e flags.
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
# Host facts for the directory seed — collected HERE (on the host; inside the
# container hostname/uname describe the container, not the machine). Same
# collection as ldap-client/index.sh so stack hosts and ldap-client-joined
# hosts carry identical metadata. All best-effort: a missing tool just leaves
# the field blank.
STACK_HOST_NAME="$(hostname 2>/dev/null || true)"
STACK_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
_iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)"
STACK_HOST_MAC=""
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
-e STACK_HOST_IP="$STACK_HOST_IP" \
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
-e STACK_HOST_OS="$STACK_HOST_OS" \
-e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \
-e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \
-e VAULT_ADDR=http://openbao:8200 \
-e VAULT_TOKEN="$VAULT_TOKEN" \
sso-manager node /bootstrap/bootstrap.js) \
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/bootstrap.js) \
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}"
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
CLIENT_ID=$(getval CLIENT_ID)
CLIENT_SECRET=$(getval CLIENT_SECRET)
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
@@ -925,10 +536,6 @@ else
fi
# ── 6. Start the proxy, wait for health ───────────────────────────────────────
# PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above.
PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)"
export PROXY_GIT_COMMIT
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
@@ -941,96 +548,7 @@ for i in $(seq 1 60); do
sleep 2
done
# ── 7. Register the SSO + proxy UIs as Host records in the proxy ──────────────
# The proxy routes EVERY hostname it serves — including its own management UI
# and the SSO's UI — off a Host record (ops/nginx_conf/proxy.conf has no
# default/self route; targetinfo.lua does a lookup for every request, full
# stop). Nothing else creates these two, so without this step https://<SSO_HOST>
# and https://<PROXY_HOST> 404 on first run. sso_enabled is left false on both:
# each app gates its own login already, and SSO-gating the SSO's own login page
# would be circular. Idempotent — skips a host that already exists.
info "Registering ${SSO_HOST} and ${PROXY_HOST} with the proxy..."
HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF
const {Host} = require('/app/models').models;
async function ensureHost(host, ip, targetPort) {
try {
await Host.get(host);
console.log('SKIP ' + host + ' (already exists)');
} catch (error) {
if (error.name !== 'EntryNotFound') throw error;
await Host.create({
host: host,
ip: ip,
targetPort: targetPort,
forcessl: true,
targetssl: false,
sso_enabled: false,
created_by: 'setup.sh',
});
console.log('CREATED ' + host + ' -> ' + ip + ':' + targetPort);
}
}
(async () => {
try {
await ensureHost($(js_str "$SSO_HOST"), 'sso-manager', 3001);
await ensureHost($(js_str "$PROXY_HOST"), '127.0.0.1', 3000);
process.exit(0);
} catch (error) {
console.error('ERROR', error.message);
process.exit(1);
}
})();
NODEEOF
) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}"
echo "$HOSTS_OUT" | sed 's/^/[setup] /'
# ── 7b. 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 "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..."
JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF || true
const {Host} = require('/app/models').models;
(async () => {
try {
try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); }
catch (e) {
if (e.name !== 'EntryNotFound') throw e;
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: true, targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
}
process.exit(0);
} catch (error) { console.error('ERROR', error.message); process.exit(1); }
})();
NODEEOF
)
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
# ── 8. Summary ───────────────────────────────────────────────────────────────
# ── 7. Summary ───────────────────────────────────────────────────────────────
echo
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
echo
@@ -1038,19 +556,10 @@ 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}"
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)"
echo
echo " First admin login credentials are in ./config/sso-secrets.js:"
echo " First admin login:"
echo " user: ${ADMIN_UID}"
echo " pass: bootstrap.adminPass"
echo
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
echo " user: proxyadmin2"
echo " pass: auth.localAdminPass in ./config/proxy-secrets.js"
echo " (only shown when the account is first created; edit ./config/proxy-secrets.js"
echo " or use the proxy UI to change it afterward)"
echo " pass: ${ADMIN_PASS}"
echo
echo " Secrets live in ./config/ (sso-secrets.js + proxy-secrets.js). Back them"
echo " up off-host — ./setup.sh snapshots to ./backups/ before each rebuild."
-44
View File
@@ -1,44 +0,0 @@
#!/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
@@ -1,66 +0,0 @@
#!/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})`);