Architecture
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 secrets store — on one Docker network. It does not fork or patch the components; it composes and configures them.
Components
| Repo / image | Role |
|---|---|
theta42/sso-manager-node |
OIDC provider + OpenLDAP directory + web UI. All-in-one image (Dockerfile.openldap). |
theta42/proxy |
OIDC-protected reverse proxy (OpenResty + Node mgmt app + Redis). All-in-one image (Dockerfile). |
theta42/jump-host |
Directory-driven SSH jump host (sshd + Node web UI). Image (Dockerfile). |
theta42/ldap-client |
Enrolls real Linux hosts into the directory (SSSD + AuthorizedKeysCommand). Also the opt-in ldap-test-host fixture. |
quay.io/openbao/openbao |
Central secrets store (Vault fork), KV-v2 at secret/. |
theta42/theta-suite (this repo) |
Composes all of the above on one network + automates first-run wiring. |
The 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 stack
┌──────────────────────────────────────────────────────────┐
│ 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`).
All four services bundle their own Redis (sso-manager, proxy, jump-host
each run a 127.0.0.1:6379 instance) and share the openbao secrets store.
Direct LDAP binds against :636 are first-class — that’s how Linux hosts do
PAM/SSSD login, sudo, and SSH-key serving, and how LDAP-native apps
authenticate — not a fallback path.
What’s exposed, what’s not
| Port | Service | On host? | Purpose |
|---|---|---|---|
443 |
proxy | yes | public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
80 |
proxy | yes | HTTP-01 for Let’s Encrypt (and redirect to 443) |
4443 |
proxy | yes (optional) | alt HTTPS listener |
3000 |
proxy | localhost/LAN | proxy mgmt UI/API (fronted by 443 normally) |
3001 |
sso-manager | localhost/LAN | SSO web UI (fronted by the proxy normally) |
636 |
sso-manager | yes | LDAPS for direct-LDAP clients (Linux hosts, LDAP-native apps) |
389 |
sso-manager | no | plain LDAP — internal only (app↔slapd over localhost) |
2222 |
jump-host | yes | SSH front door |
3002 |
jump-host | yes | jump-host web UI/API |
8080 |
openbao | yes | OpenBao UI/API for the operator (apps use openbao:8200 internally) |
Secrets (OpenBao)
Every component loads its secrets from one OpenBao instance at boot, not
from scattered config files. OpenBao runs as the openbao container
(http://openbao:8200 on theta-net, KV-v2 at secret/); each app gets a
scoped token (never the root token) whose OpenBao policy confines it to
the paths it needs:
| Service | env var | Policy | Access |
|---|---|---|---|
| sso-manager | SSO_VAULT_TOKEN |
sso-broker |
secret/sso-manager/conf, secret/users/*, secret/apps/*, secret/plugins/*; also mints per-user + per-app tokens |
| proxy | PROXY_VAULT_TOKEN |
proxy |
secret/proxy/conf (read) |
| jump-host | JUMP_VAULT_TOKEN |
jump-host |
secret/jump-host/conf (read) |
At boot each app calls @simpleworkjs/bao-conf’s init(), which deep-merges
its OpenBao path over the file-loaded @simpleworkjs/conf object — so OpenBao
is authoritative at runtime, with the ./config/*-secrets.js file kept only as
an operator-edited seed and a fail-soft fallback (init() is fail-soft, so the
app still boots from the file if OpenBao is unreachable). The proxy and
jump-host consume conf.oidc.clientSecret at require time, so init()
runs before their models load (see each app’s bin/www).
Beyond app config, OpenBao holds:
- Per-user secret storage —
secret/users/<uid>/*, browsed and edited in the SSO UI’s My Secrets page. Each user is confined to their own namespace by auser-<uid>policy; admins see all ofsecret/. - External-app tokens — an admin mints a scoped
app-<name>token (confined tosecret/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.
The first-run bootstrap
./setup.sh orchestrates first-run wiring; bootstrap/bootstrap.js does the
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:
- Build + start sso-manager, wait for
/health. - LDAP service account —
ldapaddcn=ldapclient,ou=people,<base>(anorganizationalRolewith a{SSHA512}password). The proxy binds as this DN — not the admin DN. - First admin user —
ldapaddcn=<uid>,ou=people,<base>(inetOrgPerson + posixAccount,{SSHA512}password) and add them asmemberofapp_sso_admin+app_sso_oauth_admin(the SSO’s permission check reads the group’smemberlist). - Log in as that admin via
POST /api/auth/login {uid,password}— this also validates the password end-to-end. - Register the proxy as an OIDC client via
POST /api/oauth/client(gated byapp_sso_oauth_admin, satisfied by step 3). The SSO generates theclient_id/client_secret(UUIDs) — supplied creds are ignored — so the bootstrap writes the generated creds back into./config/proxy-secrets.jsand into OpenBao atsecret/proxy/conf(the sso-manager mounts./configread-write for this; the proxy mounts it read-only). Ifproxy-secrets.jsalready holds aclientId+clientSecretmatching an existing client, they are kept; if the client exists but the file has no usable secret, the secret is rotated and written back. - Build + start the proxy + jump-host, wait for
/health. Each entrypoint pointsCONF_SECRETSat its./config/*-secrets.js, then@simpleworkjs/bao-confoverlays the OpenBao path over it (the OAuth clientSecret + LDAP bind creds come from OpenBao at runtime). - Register
<SSO_HOST>and<PROXY_HOST>as Host records in the proxy —setup.shruns 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.confhas no default/self route), so without this step neither URL resolves to anything.<SSO_HOST>targetssso-manager:3001(the Docker service),<PROXY_HOST>targets127.0.0.1:3000(the proxy’s own management app, same container). Both are created withsso_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-runningsetup.shis a no-op here.
setup.sh then prints the first-admin login + the public URLs.
How config reaches the apps
Config and secrets live in two layers: an operator-edited
./config/*-secrets.js file (gitignored, bind-mounted) and the OpenBao
overlay over it. Each entrypoint points the CONF_SECRETS env var
(@simpleworkjs/conf >= 1.2.0) at its file early, before the app starts:
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW)
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
CONF_SECRETS=/config/jump-secrets.js (jump-host, ./config RO)
@simpleworkjs/conf loads conf/base.js → <env>.js → secrets file → app_*
env, where env beats the secrets file. 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.
Why not require the SSO’s internal models?
A docker compose exec process reads conf/base.js defaults (the docker-exec
env doesn’t carry the entrypoint’s exported vars), so the SSO’s models would
bind the wrong LDAP DN. Using the openldap-clients binaries with explicit
admin creds from ./config/sso-secrets.js sidesteps that entirely, and going
through the HTTP API for the OAuth client validates the whole admin login path
end-to-end.
Idempotency
Re-running ./setup.sh converges to ./config/ + OpenBao:
- The LDAP service account + admin passwords are reset to
./config/. - Group membership is ensured (add is a no-op if already a member).
- The OAuth client is kept if
proxy-secrets.jsalready 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>/confseeds are ensured (created if absent, left alone if present).
So setup.sh is safe to re-run after editing ./config/, after a docker
compose down, or after restoring from backup.
Backups and restore
./setup.sh auto-snapshots ./config/ + LDAP + all the Redis instances to
./backups/<timestamp>/ before each rebuild (keeps the last BACKUP_KEEP,
default 5). State lives on named volumes (ldap-data, 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. 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:
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
Plugin Ecosystem
The SSO Manager utilizes a dynamic plugin registry (nodejs/services/plugin_registry.js) that automatically loads any .js file placed in the nodejs/plugins/<category> folders.
Discovery Plugins
Discovery plugins (e.g., nmap.js, proxmox.js, docker.js) run on a defined cron schedule to sync external assets into the centralized directory catalog.
Messaging Plugins
Messaging plugins (e.g., twilio.js, webhook.js) provide on-demand delivery capabilities for alerts, 2FA tokens, and notifications.
- Universal REST Webhook: Sends custom JSON payloads to platforms like Slack, Teams, or custom API endpoints securely.
- Discord Example: To send alerts to a Discord channel, create a new plugin instance of type “Universal REST Webhook”. Set the Webhook URL to your Discord webhook URL (e.g.,
https://discord.com/api/webhooks/...), the HTTP Method toPOST, and the Payload Template to{"content": "Alert for : "}. Leave the Headers and API Secret blank.
- Discord Example: To send alerts to a Discord channel, create a new plugin instance of type “Universal REST Webhook”. Set the Webhook URL to your Discord webhook URL (e.g.,
- Twilio SMS: Sends standard SMS codes.
- Fallback: If no messaging plugins are enabled, the system falls back to the legacy
voipmsintegration configured in the SSO secrets.
Secrets belonging to plugins are automatically pushed to OpenBao (secret/plugins/<id>/conf) and are never written to the local database, following the global secrets architecture.