Files
theta-suite/docs/architecture.md
T
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

8.2 KiB

layout, title
layout title
default Architecture

Architecture

← Back to Home

theta-env is a composition repo: it builds the two existing projects from their git submodules and adds the glue that wires them together. It does not fork or patch them — both projects work unchanged on their own.


The three repos

Repo Role
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/theta-env (this repo) Composes the two on one Docker network + automates first-run wiring.

The two projects are pinned as git submodules. git clone --recursive fetches all three in one step; git submodule update --remote bumps them.


The two containers

            ┌──────────────────────────────────────────────┐
            │  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, …)│
      └──────────────────────────────┘

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

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

  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 (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 (no .env)

All config and secrets live in ./config/ (gitignored, bind-mounted). Each entrypoint symlinks its file to /app/conf/secrets.js early, before the app starts:

./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 → 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?

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/:

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

So setup.sh is safe to re-run after editing ./config/, after a docker compose down, or after restoring from backup.


Backups and restore

./setup.sh auto-snapshots ./config/ + LDAP + both Redis to ./backups/<timestamp>/ before each rebuild (keeps the last BACKUP_KEEP, default 5). State lives on named volumes (ldap-data, sso-data, proxy-data) and survives recreation; down -v wipes them. Redis is persisted with AOF + RDB on those volumes. For the full manual-backup + restore runbook (full / Redis-only / LDAP-only, with the AOF-vs-RDB note), see the Backups and restore section of the README. Quick LDAP backup:

docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif

← Back to Home