Architecture

← Back to Home

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 storagesecret/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.

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:

  1. Build + start sso-manager, wait for /health.
  2. LDAP service accountldapadd cn=ldapclient,ou=people,<base> (an organizationalRole with a {SSHA512} password). The proxy binds as this DN — not the admin DN.
  3. First admin userldapadd cn=<uid>,ou=people,<base> (inetOrgPerson + posixAccount, {SSHA512} password) and add them as member of app_sso_admin + app_sso_oauth_admin (the SSO’s permission check reads the group’s member list).
  4. Log in as that admin via POST /api/auth/login {uid,password} — this also validates the password end-to-end.
  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 proxysetup.sh runs a short script inside the proxy container that calls its Host model directly (Host.create({host, ip, targetPort, ...})), rather than the proxy’s own HTTP API, since no authenticated session exists yet at this point in the run. The proxy routes every hostname purely off a Host record (ops/nginx_conf/proxy.conf has no default/self route), so without this step neither URL resolves to anything. <SSO_HOST> targets sso-manager:3001 (the Docker service), <PROXY_HOST> targets 127.0.0.1:3000 (the proxy’s own management app, same container). Both are created with sso_enabled: false — each app already gates its own login, and SSO-gating the SSO’s own login page would be circular. Skips a host that already exists, so re-running setup.sh is a no-op here.

setup.sh then prints the first-admin login + the public URLs.

How config reaches the apps

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.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).

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 to POST, and the Payload Template to {"content": "Alert for : "}. Leave the Headers and API Secret blank.
  • Twilio SMS: Sends standard SMS codes.
  • Fallback: If no messaging plugins are enabled, the system falls back to the legacy voipms integration configured in the SSO secrets.

Secrets belonging to plugins are automatically pushed to OpenBao (secret/plugins/<id>/conf) and are never written to the local database, following the global secrets architecture.

← Back to Home