# theta-env The whole theta42 identity + access stack in one repo, brought up with a single command — for home labs and small businesses. It wires together two projects that already work on their own: - **[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 users, groups, and OAuth clients. - **[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. 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. ``` ┌──────────────────────────────────────────────┐ │ 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) │ └───────────────────────────┘ ``` The proxy fronts the SSO Manager UI under TLS and protects it with OIDC login. 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. --- ## Before you begin You'll need three things set up ahead of time — a domain, DNS records, and port forwarding. The setup script can't do these for you; they're outside the host. ### 1. A domain you control You need a real DNS domain (e.g. `lab.example.com`) because the proxy issues 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). ### 2. At least two hostnames, pointing at your public IP You need a DNS **`A` record** for each hostname you want the stack to serve, all pointing at the **public IP** of the host running this stack (or your router, if the host is behind it). At a minimum: | Hostname (example) | What it serves | |--------------------|----------------| | `sso.lab.example.com` | The SSO Manager UI (login, user/group/OAuth-client admin). | | `proxy.lab.example.com`| The proxy's own management UI (add the apps you want to protect). | Two is the **minimum**. You'll almost certainly want **more**: every app you put behind the proxy needs *its own* hostname too (e.g. `wiki.lab.example.com`, `photos.lab.example.com`). Add those DNS records as you add apps — create them now if you already know what you'll front. > **Quick local test, no DNS?** You can skip real DNS by adding the hostnames > to `/etc/hosts` on the machine you browse from, pointing at the stack host. > The proxy will then fall back to a self-signed cert. Fine for kicking the > tires, not for real use. ### 3. Port forwarding: 80 and 443 to the host On your router / firewall, forward these from your public IP to the host running the stack: | Port | Why | |------|-----| | **80** (HTTP) | Required for Let's Encrypt to verify domain ownership (ACME HTTP-01 challenge). Without it, you get the self-signed fallback. | | **443** (HTTPS) | The real traffic — all the UIs and proxied apps. | Both must reach the host. Port **80 must be reachable from the internet** even though you'll only *use* 443 — that's just how Let's Encrypt validates. (If you really can't open 80, the stack still runs on the self-signed cert; you'll just see browser warnings.) Optional extra ports (only if you need them): - **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else). - **636** (LDAPS) — only if a legacy app on another machine binds to LDAP directly over the network. The proxy itself reaches LDAP over the internal Docker network, so you do **not** need to expose 636 for the stack to work. ### 4. Docker + Docker Compose Any recent Docker with Compose — the v2 plugin (`docker compose`) or the v1 standalone (`docker-compose`) both work. --- ## Quickstart ```bash git clone --recursive https://github.com/theta42/theta-env.git cd theta-env cp .env.example .env # then edit .env (see below) ./setup.sh ``` `./setup.sh` is idempotent — re-run it any time to converge the stack to your `.env`. It: 1. Builds + starts the SSO Manager container, waits for it to be healthy. 2. Runs the bootstrap (`bootstrap/bootstrap.js`) **inside** the SSO container, which: - creates the LDAP service account the proxy binds as (`cn=ldapclient,ou=people,`), - creates your first admin user and adds them to the `app_sso_admin` + `app_sso_oauth_admin` groups, - registers the proxy as an OIDC client in the SSO, and - prints the client id + secret. 3. Writes `./proxy.env` (the proxy's config — OIDC endpoints, LDAP bind, client creds) from your `.env` + the bootstrap output. 4. Builds + starts the proxy container, waits for it to be healthy. 5. Prints your first admin login + the public URLs. ### `.env` — the values you must set Copy `.env.example` to `.env` and at minimum set: | Key | What it is | |-----|------------| | `LDAP_BASE_DN` | Your directory base, e.g. `dc=lab,dc=local`. | | `LDAP_ADMIN_PASS` | The LDAP root password. **Save it** — needed for raw LDAP admin. | | `JWT_SECRET` | Signs the SSO's access/refresh tokens. Leave blank to auto-generate + persist. **Save it.** | | `SSO_HOST` | Public hostname the proxy serves the SSO UI at, e.g. `sso.lab.local`. | | `PROXY_HOST` | Public hostname the proxy serves its own mgmt UI at, e.g. `proxy.lab.local`. | | `BOOTSTRAP_ADMIN_UID` / `BOOTSTRAP_ADMIN_PASS` | Your first admin login. Re-running `setup.sh` resets this password. | > **Values with spaces:** quote them, e.g. `ORG_NAME="My Org"` or > `SMTP_FROM="Theta SSO "`. Quotes are optional but > recommended anywhere a value contains spaces — `setup.sh` and `docker > compose` both strip a single pair of matching outer quotes. Optional: `BOOTSTRAP_ADMIN_EMAIL`, `LDAP_SERVICE_PASS` (auto-generated if blank), `SMTP_*` (for SSO password-reset/invite emails), and host port overrides (`SSO_PORT`, `LDAPS_PORT`, `HTTP_PORT`, `HTTPS_PORT`, `HTTPS_ALT_PORT`, `MGMT_PORT`). See `.env.example` for the full list with comments. --- ## After setup - **SSO Manager UI**: `https://` — log in as your bootstrap admin to add users, groups, and OAuth clients. (First-run fallback: `http://:3001`, reachable on the LAN by default.) - **Proxy mgmt UI**: `https://` — add the Host records you want to protect with OIDC. (First-run fallback: `http://:3000`, reachable on the LAN by default.) - **Direct LDAP for legacy apps**: bind to `ldaps://:636` as `cn=admin,` (admin) or `cn=ldapclient,ou=people,` (read-only service account the bootstrap created). Use LDAPS, not plain LDAP. --- ## Logs The stack runs under Docker Compose with two services — `sso-manager` and `proxy`. Both the Node app and, for the SSO, OpenLDAP write to the container's stdout/stderr, so `docker compose logs` is the primary view. ```bash # Follow both services live docker compose logs -f # One service docker compose logs -f sso-manager docker compose logs -f proxy # Last 200 lines and keep following docker compose logs --tail=200 -f proxy # Only the last 10 minutes docker compose logs --since=10m sso-manager ``` The plain container names work too (`docker logs -f sso-manager`, `docker logs -f proxy`) — handy if you started the stack without Compose. ### Proxy nginx access/error logs OpenResty writes its access/error logs to files inside the container (`/var/log/nginx`, on the `proxy-logs` volume), so they do **not** appear in `docker logs proxy`. Tail them directly: ```bash docker compose exec proxy tail -f /var/log/nginx/access.log docker compose exec proxy tail -f /var/log/nginx/error.log ``` ### SSO / LDAP logs slapd runs with `-d 0` and logs to stderr, so LDAP output is already in `docker compose logs sso-manager`. For a targeted health check, exec into the SSO and query the directory directly: ```bash docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \ -D "cn=admin,${LDAP_BASE_DN}" -W -b "${LDAP_BASE_DN}" ``` --- ## Backups The directory lives in the `ldap-data` Docker volume. Back it up with `slapcat` (the portable LDIF export — survives OpenLDAP version upgrades): ```bash docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "$LDAP_BASE_DN" > backup.ldif ``` Restore is `ldapadd`/`ldapmodify` from that LDIF into a fresh directory. Also keep your `.env` (it holds `LDAP_ADMIN_PASS` + `JWT_SECRET`) and `proxy.env`. --- ## Running each project standalone The two submodules work on their own — this repo just composes them: - **SSO Manager alone**: ```bash cd sso-manager-node cp secrets.js.example nodejs/conf/secrets.js # edit it docker compose up -d --build ``` See its [DEPLOYMENT.md](sso-manager-node/DEPLOYMENT.md). - **Proxy alone** (pointing at any external SSO + LDAP via `app_*` env or a mounted `secrets.js`): ```bash cd proxy docker compose up -d --build ``` See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md). No cross-repo file edits are needed at runtime — the unified stack is pure composition (one compose file + one bootstrap script). --- ## How the first-run wiring works `bootstrap/bootstrap.js` runs inside the SSO Manager container (bind-mounted read-only from this repo) and is deliberately self-contained: it uses only Node built-ins (`child_process`, `crypto`) + global `fetch`. LDAP operations use the `openldap-clients` binaries (`ldapadd`/`ldapsearch`/`ldapmodify`) with explicit admin creds from `.env`; the OAuth client is created via the SSO's own HTTP API (logging in as the bootstrapped admin, which also validates that admin's password end-to-end). It does **not** `require` the SSO's internal models, so it never has to fight the app's config layer. It's idempotent: re-running converges to your `.env` values. The LDAP service account + admin passwords are reset to `.env` on each run; the OAuth client is created if missing, left alone if `proxy.env` is present, or rotated if `proxy.env` was lost (so a wiped-and-restored proxy gets a secret it can read). Passwords are stored as `{SSHA512}` (the SSO's `hashPasswordSSHA512`, replicated exactly in the bootstrap) so the SSO can verify them on bind. --- ## Security notes 1. **Only expose 443 (and optionally 4443) to the internet.** The SSO web port (`3001`) and the proxy mgmt UI (`3000`) default to `0.0.0.0` for first-run convenience, so they're reachable on your LAN (they're login-protected, but it widens the attack surface). The proxy fronts both under TLS in normal use, so set `SSO_BIND=127.0.0.1` and `MGMT_BIND=127.0.0.1` in `.env` to lock them to the host once you're up and running. LDAPS (`636`) is the only LDAP listener that should cross the network. 2. **Persist + protect `.env` and `proxy.env`.** They hold `LDAP_ADMIN_PASS`, `JWT_SECRET`, the LDAP service password, and the OAuth client secret. `setup.sh` writes `proxy.env` mode `0600`; both are in `.gitignore`. 3. **LDAPS uses the SSO's self-signed cert by default.** The proxy binds with `app_ldap__tlsOptions__rejectUnauthorized=false`. For strict trust, mount the SSO's cert (`ldap-certs` volume) into the proxy and set `app_ldap__tlsOptions__ca=` in `proxy.env`. 4. **Re-running `setup.sh` resets the bootstrap admin + service passwords to `.env`.** If you change a user's password in the SSO UI later, re-running `setup.sh` will reset the bootstrap admin's password back to `BOOTSTRAP_ADMIN_PASS`. 5. Both containers run their app process as root (matching the bare-metal systemd units) for simplicity at this scale. Harden to a non-root user for a stricter deployment. --- ## Repo layout ``` theta-env/ ├── .env.example # copy to .env, edit ├── docker-compose.yml # sso-manager + proxy on one bridge net ├── setup.sh # one-command idempotent bring-up ├── bootstrap/ │ └── bootstrap.js # runs in the sso-manager container ├── sso-manager-node/ # git submodule └── proxy/ # git submodule ``` `./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`.