theta-env: unified SSO Manager + Proxy stack with one-command setup

Composes theta42/sso-manager-node and theta42/proxy (as git submodules) on a
single Docker network and automates first-run wiring.

- docker-compose.yml: sso-manager (build ./sso-manager-node/Dockerfile.openldap)
  + proxy (build ./proxy/Dockerfile) on theta-net; SSO UI + mgmt port bound to
  localhost, LDAPS published, proxy 80/443/4443 published.
- setup.sh: idempotent one-command bring-up — validates .env, starts SSO, runs
  the bootstrap, writes ./proxy.env, starts the proxy, prints admin login.
- bootstrap/bootstrap.js: runs inside the sso-manager container (self-contained,
  Node built-ins + fetch only) — creates the LDAP service account, first admin
  (+ app_sso_admin/app_sso_oauth_admin membership), registers the proxy as an
  OIDC client via the SSO HTTP API, emits CLIENT_ID/CLIENT_SECRET.
- .env.example: all tunables (LDAP_BASE_DN, LDAP_ADMIN_PASS, JWT_SECRET,
  SSO_HOST, PROXY_HOST, BOOTSTRAP_ADMIN_*, LDAP_SERVICE_PASS, SMTP_*, ports).
- README.md + docs/ (Jekyll site for GitHub Pages): quickstart, architecture,
  standalone usage.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:04:36 -04:00
commit 9fb240ff45
14 changed files with 1385 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
# 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.
# ── Directory / domain (REQUIRED-ish — set these) ────────────────────────────
# Your organization's LDAP base DN. Derives the LDAP domain + cert defaults.
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_PASS=change-me-ldap-admin-password
ORG_NAME=My Org
# ── Public hostnames (REQUIRED) ───────────────────────────────────────────────
# The proxy serves the SSO Manager UI at https://<SSO_HOST> and its own
# management UI at https://<PROXY_HOST>. Both must resolve (DNS or hosts file)
# to the host running this stack, and the proxy must be able to complete ACME
# (port 80 reachable) for real certs — or use the self-signed fallback on LAN.
SSO_HOST=sso.example.com
PROXY_HOST=proxy.example.com
# ── First admin (created in the SSO by the bootstrap) ───────────────────────
# The bootstrap creates this user in LDAP, adds them to app_sso_admin +
# 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
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_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
# Leave blank to auto-generate (NOT persisted across container recreation —
# set it explicitly for a stable install).
JWT_SECRET=
# ── Optional: outbound email (SSO password resets / invites) ─────────────────
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=
# ── Optional: host port overrides ───────────────────────────────────────────
# SSO web UI (mapped to host for first-run convenience; the proxy fronts it in
# normal use, so you can leave it unmapped by setting SSO_PORT=0).
SSO_PORT=3001
LDAPS_PORT=636
# Proxy listeners:
HTTP_PORT=80
HTTPS_PORT=443
HTTPS_ALT_PORT=4443
MGMT_PORT=3000
# ── Optional: LDAP TLS cert CN (hostname LDAPS clients verify) ───────────────
# 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=
+7
View File
@@ -0,0 +1,7 @@
# Local deployment config — contains secrets (LDAP_ADMIN_PASS, JWT_SECRET,
# OAuth client secret, LDAP service password). Never commit.
.env
proxy.env
# Docker Compose runtime artifacts
*.log
+6
View File
@@ -0,0 +1,6 @@
[submodule "sso-manager-node"]
path = sso-manager-node
url = https://github.com/theta42/sso-manager-node.git
[submodule "proxy"]
path = proxy
url = https://github.com/theta42/proxy.git
+205
View File
@@ -0,0 +1,205 @@
# theta-env
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 exists for people whose needs are met by these two projects and who want to
run them "very simply." Each project still works **standalone** (its own
`docker compose up`); this repo just wires them together and automates the
first-run glue.
```
┌──────────────────────────────────────────────┐
│ 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.
---
## 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,<base>`),
- 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.
You need **Docker** + **Docker Compose** (v2 plugin `docker compose` or v1
standalone `docker-compose` both work).
### `.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. |
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.
### DNS
`SSO_HOST` and `PROXY_HOST` must resolve to the host running the stack. On a
real network, add DNS records; for a quick local try, add them to `/etc/hosts`
pointing at the host. The proxy auto-issues Let's Encrypt certs when port **80**
is reachable from the internet; on a LAN without that, it serves a self-signed
fallback cert (browsers will warn — that's expected for home-lab use).
---
## After setup
- **SSO Manager UI**: `https://<SSO_HOST>` — log in as your bootstrap admin to
add users, groups, and OAuth clients. (First-run fallback:
`http://127.0.0.1:3001`.)
- **Proxy mgmt UI**: `https://<PROXY_HOST>` — add the Host records you want to
protect with OIDC. (First-run fallback: `http://127.0.0.1:3000`.)
- **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.
---
## 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's web port
(`3001`) is bound to localhost — the proxy fronts it. 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=<path>` 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
```
The two submodules pin a known-good version of each project. Update them with
`git submodule update --remote` (then re-run `setup.sh` to rebuild).
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env node
/*
* theta-env bootstrap — runs inside the sso-manager container to wire the
* proxy into a fresh SSO Manager. Invoked by setup.sh:
*
* docker compose exec sso-manager node /bootstrap/bootstrap.js
*
* It is intentionally self-contained: only Node built-ins (child_process,
* crypto) + global fetch. No requiring of the SSO's internal models (which
* would read the wrong conf.ldap in a docker-exec process and risk model
* side effects). LDAP ops use the openldap-clients binaries (ldapadd /
* ldapsearch / ldapmodify) with explicit admin creds from the environment;
* the OAuth client is created via the SSO's own HTTP API (logging in as the
* bootstrapped admin, which also validates the admin password end-to-end).
*
* Idempotent: re-running converges to the .env values. The LDAP service
* account + admin passwords are reset to .env on each run; the OAuth client
* is created if missing, or rotated only if proxy.env is absent (a lost
* proxy.env needs a fresh secret the proxy can actually read).
*
* Inputs (env, set by setup.sh from .env):
* LDAP_BASE_DN, LDAP_ADMIN_PASS — directory root creds
* BOOTSTRAP_ADMIN_UID/PASS/EMAIL — first admin to create
* LDAP_SERVICE_PASS — proxy bind account password
* SSO_HOST, PROXY_HOST — public hostnames
* PROXY_ENV_EXISTS (1|0) — set by setup.sh
*
* Output (stdout, KEY=VALUE for setup.sh to parse): CLIENT_ID, CLIENT_SECRET,
* and ALREADY_CONFIGURED. Progress logs go to stderr.
*/
'use strict';
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const BASE_DN = process.env.LDAP_BASE_DN || 'dc=example,dc=com';
const ADMIN_PASS = process.env.LDAP_ADMIN_PASS || 'admin';
const BIND_DN = `cn=admin,${BASE_DN}`;
const LDAP_URL = 'ldap://localhost:389';
const ADMIN_UID = process.env.BOOTSTRAP_ADMIN_UID || 'admin';
const ADMIN_PASS = process.env.BOOTSTRAP_ADMIN_PASS || 'admin';
const ADMIN_EMAIL = process.env.BOOTSTRAP_ADMIN_EMAIL || '';
const SVC_PASS = process.env.LDAP_SERVICE_PASS || 'service';
const SSO_HOST = process.env.SSO_HOST || 'sso.example.com';
const PROXY_HOST = process.env.PROXY_HOST || 'proxy.example.com';
const PROXY_ENV_EXISTS = process.env.PROXY_ENV_EXISTS === '1';
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
const SSO_INTERNAL = 'http://localhost:3001';
const CLIENT_NAME = 'theta-proxy';
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
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`);
// 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) {
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
}
// Run an openldap client binary; returns {code, stdout, stderr}. Does not throw
// on non-zero (ldapsearch exits 32 for "no such object", which we branch on).
function ldap(bin, args, ldif) {
try {
const stdout = execFileSync(bin, args, {
input: ldif ? Buffer.from(ldif) : undefined,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, LDAPTLS_REQCERT: 'never' },
});
return { code: 0, stdout, stderr: '' };
} catch (e) {
return { code: e.status || 1, stdout: (e.stdout || '').toString(), stderr: (e.stderr || '').toString() };
}
}
const bindArgs = (extra) => ['-x', '-H', LDAP_URL, '-D', BIND_DN, '-w', ADMIN_PASS, ...(extra || [])];
function entryExists(dn) {
const r = ldap('ldapsearch', bindArgs(['-b', dn, '-s', 'base', '(objectClass=*)', 'dn']));
return r.code === 0;
}
function ldapAdd(ldif) {
return ldap('ldapadd', bindArgs(), ldif);
}
function ldapModify(ldif) {
return ldap('ldapmodify', bindArgs(), ldif);
}
// ── 1. LDAP service account for the proxy ───────────────────────────────────
function ensureServiceAccount() {
const pw = hashPasswordSSHA512(SVC_PASS);
if (entryExists(SVC_DN)) {
log(`Service account ${SVC_DN} exists — resetting password to .env`);
const r = ldapModify([
`dn: ${SVC_DN}`,
'changetype: modify',
'replace: userPassword',
`userPassword: ${pw}`,
'',
].join('\n'));
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
return;
}
log(`Creating service account ${SVC_DN}`);
const r = ldapAdd([
`dn: ${SVC_DN}`,
'objectClass: organizationalRole',
'objectClass: top',
'cn: ldapclient',
`userPassword: ${pw}`,
'',
].join('\n'));
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
}
// ── 2. First admin user ─────────────────────────────────────────────────────
function ensureAdmin() {
const pw = hashPasswordSSHA512(ADMIN_PASS);
if (entryExists(ADMIN_DN)) {
log(`Admin ${ADMIN_DN} exists — resetting password to .env and ensuring groups`);
ldapModify([
`dn: ${ADMIN_DN}`,
'changetype: modify',
'replace: userPassword',
`userPassword: ${pw}`,
'',
].join('\n'));
} else {
log(`Creating admin ${ADMIN_DN}`);
const entry = [
`dn: ${ADMIN_DN}`,
'objectClass: inetOrgPerson',
'objectClass: posixAccount',
'objectClass: top',
`cn: ${ADMIN_UID}`,
`sn: Admin`,
`uid: ${ADMIN_UID}`,
'uidNumber: 10000',
'gidNumber: 10000',
`homeDirectory: /home/${ADMIN_UID}`,
`userPassword: ${pw}`,
];
if (ADMIN_EMAIL) entry.push(`mail: ${ADMIN_EMAIL}`);
entry.push('');
const r = ldapAdd(entry.join('\n'));
if (r.code !== 0) throw new Error(`ldapadd admin failed: ${r.stderr.trim()}`);
}
// Ensure group membership (idempotent — ignore "value already exists").
for (const g of ADMIN_GROUPS) {
const groupDn = `cn=${g},ou=groups,${BASE_DN}`;
const r = ldapModify([
`dn: ${groupDn}`,
'changetype: modify',
'add: member',
`member: ${ADMIN_DN}`,
'',
].join('\n'));
if (r.code === 0) log(` added ${ADMIN_UID} to ${g}`);
else if (/already exists|Type or value exists/i.test(r.stderr)) log(` already in ${g}`);
else log(` group ${g} warning:`, r.stderr.trim());
}
}
// ── 3. Login as the admin (validates the password) ──────────────────────────
async function login() {
const res = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_PASS }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`admin login failed (${res.status}): ${text}`);
}
const data = await res.json();
if (!data.token) throw new Error(`admin login returned no token: ${JSON.stringify(data)}`);
log(`Logged in as ${ADMIN_UID}`);
return data.token;
}
// ── 4. OAuth client for the proxy ───────────────────────────────────────────
async function findClient(token) {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`list OAuth clients failed (${res.status})`);
const data = await res.json();
const list = (data && data.results) || [];
return list.find((c) => c.name === CLIENT_NAME) || null;
}
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: CLIENT_NAME,
description: 'theta-env proxy (auto-registered)',
redirect_uris: [REDIRECT_URI],
scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`create OAuth client failed (${res.status}): ${text}`);
}
const data = await res.json();
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 ${CLIENT_NAME} (${id})`);
return { id, secret };
}
async function rotateClient(token, id) {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
method: 'POST',
headers: { 'auth-token': token },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`rotate OAuth client failed (${res.status}): ${text}`);
}
const data = await res.json();
if (!data.client_secret) throw new Error(`rotate returned no secret: ${JSON.stringify(data)}`);
log(`Rotated secret for OAuth client ${id}`);
return { id, secret: data.client_secret };
}
(async function main() {
try {
log(`Base DN: ${BASE_DN}`);
ensureServiceAccount();
ensureAdmin();
const token = await login();
const existing = await findClient(token);
if (!existing) {
const { id, secret } = await createClient(token);
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
} else if (PROXY_ENV_EXISTS) {
log(`OAuth client ${CLIENT_NAME} exists and proxy.env present — nothing to do`);
out('CLIENT_ID', existing.client_id);
out('CLIENT_SECRET', '__UNCHANGED__');
out('ALREADY_CONFIGURED', '1');
} else {
log(`OAuth client ${CLIENT_NAME} exists but proxy.env is missing — rotating secret`);
const { id, secret } = await rotateClient(token, existing.client_id);
out('CLIENT_ID', id);
out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0');
}
log('Done.');
process.exit(0);
} catch (e) {
process.stderr.write(`[bootstrap] ERROR: ${e.message || e}\n`);
process.exit(1);
}
})();
+103
View File
@@ -0,0 +1,103 @@
# 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,
# ldaps://sso-manager:636 for LDAP) without exposing the SSO's HTTP port to
# the internet. The proxy is the public front (80/443); the SSO sits behind it.
#
# Each project builds from its git submodule:
# ./sso-manager-node -> Dockerfile.openldap (app + OpenLDAP + Redis)
# ./proxy -> Dockerfile (OpenResty + app + Redis)
# So `git clone --recursive` is required to get the submodules first.
#
# First-run wiring (LDAP service account, first admin, OAuth client, proxy
# config) is automated by ./setup.sh, which runs bootstrap/bootstrap.js inside
# the sso-manager container and writes ./proxy.env (the proxy's env_file).
services:
sso-manager:
build:
context: ./sso-manager-node
dockerfile: Dockerfile.openldap
container_name: sso-manager
restart: unless-stopped
networks: [theta-net]
ports:
# SSO web UI — bind to localhost only (first-run / admin convenience). In
# normal use the proxy fronts it at https://<SSO_HOST>; don't expose 3001
# to the LAN. Set SSO_PORT=0 in .env to still map (random) or firewall it.
- "127.0.0.1:${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.
- "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
environment:
- LDAP_BASE_DN=${LDAP_BASE_DN:-dc=example,dc=com}
- LDAP_DOMAIN=${LDAP_DOMAIN:-}
- LDAP_ADMIN_PASS=${LDAP_ADMIN_PASS:-admin}
- ORG_NAME=${ORG_NAME:-SSO Manager}
- LDAP_CERT_CN=${LDAP_CERT_CN:-}
- app_oauth__jwtSecret=${JWT_SECRET}
# OIDC issuer = the browser-facing URL the proxy serves the SSO at.
- app_oauth__issuer=https://${SSO_HOST}
- app_name=${ORG_NAME:-SSO Manager}
- app_smtp__host=${SMTP_HOST:-}
- app_smtp__port=${SMTP_PORT:-587}
- app_smtp__user=${SMTP_USER:-}
- app_smtp__pass=${SMTP_PASS:-}
- app_smtp__from=${SMTP_FROM:-}
- NODE_ENV=production
- NODE_PORT=3001
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
# Bind-mount the bootstrap script so `docker compose exec sso-manager node
# /bootstrap/bootstrap.js` can run it (read-only).
- ./bootstrap:/bootstrap:ro
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
proxy:
build:
context: ./proxy
dockerfile: Dockerfile
container_name: proxy
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${HTTPS_ALT_PORT:-4443}:4443"
# Management UI/API — localhost only (the front proxies it under TLS in
# normal use; exposed on localhost for first-run setup / healthcheck).
- "127.0.0.1:${MGMT_PORT:-3000}:3000"
# Written by setup.sh from .env + the bootstrap output (OAuth client creds).
# setup.sh creates it before starting the proxy, so it always exists.
env_file:
- ./proxy.env
volumes:
- proxy-cache:/var/cache/nginx/proxy
- proxy-logs:/var/log/nginx
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
theta-net:
driver: bridge
volumes:
ldap-data:
ldap-certs:
proxy-cache:
proxy-logs:
+9
View File
@@ -0,0 +1,9 @@
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-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
+129
View File
@@ -0,0 +1,129 @@
---
layout: default
title: Architecture
---
# Architecture
[← Back to Home](index.html)
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`](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/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`) + global `fetch`:
1. **Build + start sso-manager**, wait for `/health`.
2. **LDAP service account**`ldapadd` `cn=ldapclient,ou=people,<base>` (an
`organizationalRole` with a `{SSHA512}` password). The proxy binds as this
DN — not the admin DN.
3. **First admin user**`ldapadd` `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), capturing the raw
`client_secret` (shown once). If the client already exists and `proxy.env` is
present, leave it; if `proxy.env` was lost, rotate the secret so a restored
proxy gets one it can read.
6. **Write `./proxy.env`** (the proxy's `env_file`) from `.env` + the bootstrap
output — all `app_*` env overrides so the proxy reads them via
`@simpleworkjs/conf` (≥1.1.0).
7. **Build + start the proxy**, wait for `/health`.
`setup.sh` then prints the first-admin login + the public URLs.
### 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 `app_*` vars), so the SSO's models
would bind the wrong LDAP DN. Using the `openldap-clients` binaries with explicit
admin creds 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 `.env`:
- The LDAP service account + admin passwords are **reset to `.env`**.
- Group membership is ensured (add is a no-op if already a member).
- The OAuth client is left alone if `proxy.env` exists, rotated if not.
So `setup.sh` is safe to re-run after editing `.env`, after a `docker compose
down`, or after restoring from backup.
## Backups
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "$LDAP_BASE_DN" > backup.ldif
```
Keep your `.env` (holds `LDAP_ADMIN_PASS` + `JWT_SECRET`) and `proxy.env` too.
Restore is `ldapadd`/`ldapmodify` from the LDIF into a fresh directory, then
re-run `./setup.sh`.
[← Back to Home](index.html)
+96
View File
@@ -0,0 +1,96 @@
---
layout: default
title: Home
---
# theta-env
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 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.
## Quick start
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp .env.example .env # edit the REQUIRED values (below)
./setup.sh
```
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
time to converge the stack to your `.env`.
See the [Quickstart Guide](quickstart.html) for a walkthrough of every `.env`
value 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.
## What you get
- **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).
## The `.env` values you must set
| Key | What it is |
|-----|------------|
| `LDAP_BASE_DN` | Directory base, e.g. `dc=lab,dc=local`. |
| `LDAP_ADMIN_PASS` | LDAP root password. **Save it.** |
| `JWT_SECRET` | Signs the SSO's tokens. Leave blank to auto-generate + persist. **Save it.** |
| `SSO_HOST` | Public hostname the proxy serves the SSO UI at. |
| `PROXY_HOST` | Public hostname the proxy serves its own mgmt UI at. |
| `BOOTSTRAP_ADMIN_UID` / `BOOTSTRAP_ADMIN_PASS` | Your first admin login. |
See `.env.example` for the full list (SMTP, port overrides, LDAP cert CN, …).
## 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) │
└───────────────────────────┘
```
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.
## Documentation
- [Quickstart Guide](quickstart.html) — full walkthrough of `.env` + `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.
+141
View File
@@ -0,0 +1,141 @@
---
layout: default
title: Quickstart
---
# Quickstart Guide
[← Back to Home](index.html)
## Prerequisites
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
compose` or the v1 standalone `docker-compose` both work).
- Two hostnames that resolve to the host: one for the SSO UI (`SSO_HOST`), one
for the proxy mgmt UI (`PROXY_HOST`). On a real network add DNS records; for a
local try, add them to `/etc/hosts`.
- Port **80 + 443** reachable from the internet if you want Let's Encrypt
certs; otherwise the proxy serves a self-signed fallback (browsers warn —
expected for LAN use).
## 1. Clone
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
```
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
step. If you forgot it:
```bash
git submodule update --init --recursive
```
## 2. Configure `.env`
```bash
cp .env.example .env
```
Edit `.env`. The **required** values:
| Key | Example | Notes |
|-----|---------|-------|
| `LDAP_BASE_DN` | `dc=lab,dc=local` | your directory base |
| `LDAP_ADMIN_PASS` | `...` | LDAP root password — **save it** |
| `JWT_SECRET` | _(blank)_ | leave blank to auto-generate + persist — **save it** |
| `SSO_HOST` | `sso.lab.local` | hostname the proxy serves the SSO UI at |
| `PROXY_HOST` | `proxy.lab.local` | hostname the proxy serves its own UI at |
| `BOOTSTRAP_ADMIN_UID` | `admin` | your first admin login |
| `BOOTSTRAP_ADMIN_PASS` | `...` | first admin password |
Optional: `BOOTSTRAP_ADMIN_EMAIL`, `LDAP_SERVICE_PASS` (auto-generated if blank),
`SMTP_*` (for SSO password-reset/invite emails), `LDAP_CERT_CN`, and host port
overrides (`SSO_PORT`, `LDAPS_PORT`, `HTTP_PORT`, `HTTPS_PORT`,
`HTTPS_ALT_PORT`, `MGMT_PORT`). See `.env.example` for the full commented list.
## 3. Run
```bash
./setup.sh
```
What happens:
1. Validates `.env` (copies from `.env.example` if missing, then exits so you
can edit it).
2. Builds + starts **sso-manager**, waits for `/health`.
3. Runs the **bootstrap** inside the sso-manager container — creates the LDAP
service account, your first admin, and the proxy's OAuth client, and prints
the client id + secret.
4. Writes **`./proxy.env`** (the proxy's `app_*` config) from `.env` + the
bootstrap output.
5. Builds + starts **proxy**, waits for `/health`.
6. Prints your first-admin login + the public URLs.
The first run builds two Docker images (a few minutes). Subsequent runs are
fast.
## 4. Point DNS at the host
`SSO_HOST` and `PROXY_HOST` must resolve to the host running the stack. Add DNS
records, or for a local try:
```bash
echo "127.0.0.1 sso.lab.local proxy.lab.local" | sudo tee -a /etc/hosts
```
(The proxy needs port 80 reachable for Let's Encrypt; on a LAN without that it
serves a self-signed cert — browsers will warn, which is fine for home-lab use.)
## 5. Log in
Open `https://<SSO_HOST>` and log in as your bootstrap admin
(`BOOTSTRAP_ADMIN_UID` / `BOOTSTRAP_ADMIN_PASS`). From there you can add users,
groups, and OAuth clients.
The proxy mgmt UI is at `https://<PROXY_HOST>` (same admin SSO login protects
it). Add the Host records you want to protect with OIDC.
First-run fallbacks (if DNS/TLS isn't ready yet): SSO UI at
`http://127.0.0.1:3001`, proxy UI at `http://127.0.0.1:3000`.
## Re-running
`./setup.sh` is **idempotent** — safe to re-run after editing `.env`, after a
`docker compose down`, or after restoring from backup. It converges the stack to
your `.env` values (LDAP service account + admin passwords are reset to `.env`;
the OAuth client is left alone if `proxy.env` exists).
## Direct LDAP for legacy apps
Legacy apps bind LDAP directly over LDAPS:
```bash
ldapsearch -x -H ldaps://<host>:636 \
-D "cn=ldapclient,ou=people,dc=lab,dc=local" -W \
-b "ou=people,dc=lab,dc=local" '(objectClass=posixAccount)' cn mail
```
Use the `cn=ldapclient` service account (read-only, the bootstrap created it)
or the admin DN. Use LDAPS (636), not plain LDAP.
## Backups
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "$LDAP_BASE_DN" > backup-$(date +%F).ldif
```
Keep `.env` + `proxy.env` alongside it. Restore is `ldapadd`/`ldapmodify` into a
fresh directory, then re-run `./setup.sh`.
## Next steps
- Add users / groups in the SSO UI.
- Add Host records in the proxy UI to protect your apps with OIDC.
- See [Architecture](architecture.html) for how it all fits together, and
[Standalone](standalone.html) to run either project on its own.
[← Back to Home](index.html)
+111
View File
@@ -0,0 +1,111 @@
---
layout: default
title: Standalone
---
# Running each project standalone
[← Back to Home](index.html)
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.
## SSO Manager alone
The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
```bash
git clone https://github.com/theta42/sso-manager-node.git
cd sso-manager-node
# Option A: configure via app_* env (preferred for Docker):
LDAP_ADMIN_PASS='choose-a-strong-password' \
JWT_SECRET="$(openssl rand -hex 32)" \
docker compose up -d --build
# Option B: configure via a file:
cp secrets.js.example nodejs/conf/secrets.js # edit it
docker compose up -d --build
```
- 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.1.0 for `app_*` env overrides. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal
```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -n 'Your Org' -o 3001
sudo systemctl enable --now sso-manager
```
Idempotent — re-run to update. See the SSO Manager
[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
## Proxy alone
The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
```bash
git clone https://github.com/theta42/proxy.git
cd proxy
# Wire it to an external SSO + LDAP via app_* env (or nodejs/conf/secrets.js):
cat > .env <<EOF
app_oidc__issuer=https://sso.example.com
app_oidc__authorizationEndpoint=https://sso.example.com/oauth/authorize
app_oidc__endSessionEndpoint=https://sso.example.com/oauth/logout
app_oidc__tokenEndpoint=https://sso.example.com/oauth/token
app_oidc__userinfoEndpoint=https://sso.example.com/oauth/userinfo
app_oidc__clientId=...
app_oidc__clientSecret=...
app_oidc__redirectUri=https://proxy.example.com/api/auth/oidc/callback
app_ldap__url=ldaps://sso.example.com:636
app_ldap__bindDN=cn=ldapclient,ou=people,dc=example,dc=com
app_ldap__bindPassword=...
app_ldap__searchBase=ou=people,dc=example,dc=com
app_ldap__userFilter=(objectClass=posixAccount)
app_ldap__tlsOptions__rejectUnauthorized=false
EOF
docker compose up -d --build
```
- Proxy (public, auto-SSL): `https://<host>/`
- Mgmt UI / API: `http://127.0.0.1:3000/`
- Health: `http://127.0.0.1:3000/health`
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
### Bare metal
```bash
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
See the proxy
[Docker guide](https://theta42.github.io/proxy/docker.html) /
[installation guide](https://theta42.github.io/proxy/installation.html).
## Mixing and matching
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.
2. Set the SSO's `OAUTH_ISSUER` / `app_oauth__issuer` to the browser-facing HTTPS
URL the proxy serves the SSO at.
3. Register the proxy as an OIDC client in the SSO, with `redirectUri` matching
the proxy's callback.
4. Point the proxy's `app_ldap__url` at the SSO's LDAPS + create a dedicated
`cn=ldapclient` service account.
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
+1
Submodule proxy added at 94ad6143cc
Executable
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
#
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
#
# git clone --recursive <theta-env> && cd theta-env
# cp .env.example .env # edit the REQUIRED values
# ./setup.sh
#
# Idempotent: safe to re-run. It (re)starts the SSO Manager, runs the bootstrap
# (which converges the LDAP service account / first admin / OAuth client to the
# .env values), writes ./proxy.env (the proxy's env_file), then starts the proxy.
#
# What it does, in order:
# 1. Validate .env (copy from .env.example if missing) + the REQUIRED values.
# 2. docker compose up -d sso-manager; wait for /health.
# 3. docker compose exec sso-manager node /bootstrap/bootstrap.js
# -> prints CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout.
# 4. Write ./proxy.env from .env + the bootstrap output (the proxy's app_* env).
# 5. docker compose up -d proxy; wait for /health.
# 6. Print the first-admin login + the public URLs.
#
# Requires: docker + docker compose (v1 standalone or v2 plugin). The compose
# file uses `version: '3.8'` + single-level ${VAR} interpolation so v1 works.
set -euo pipefail
cd "$(dirname "$0")"
# ── Helpers ──────────────────────────────────────────────────────────────────
info() { printf '\033[1;34m[setup]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[setup]\033[0m %s\n' "$*" >&2; }
error() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; }
die() { error "$*"; exit 1; }
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE=(docker-compose)
else
die "docker compose not found. Install Docker Compose (v2 plugin or v1 standalone)."
fi
# ── 1. Load + validate .env ───────────────────────────────────────────────────
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
cp .env.example .env
info "Created .env from .env.example — EDIT IT and re-run ./setup.sh."
info "Required: LDAP_ADMIN_PASS, JWT_SECRET, SSO_HOST, PROXY_HOST, BOOTSTRAP_ADMIN_PASS."
exit 0
else
die ".env not found and no .env.example to copy from."
fi
fi
# shellcheck disable=SC1091
set -a; source .env; set +a
require() { [[ -n "${!1:-}" ]] || die ".env is missing required key: $1"; }
require LDAP_BASE_DN
require LDAP_ADMIN_PASS
require SSO_HOST
require PROXY_HOST
require BOOTSTRAP_ADMIN_UID
require BOOTSTRAP_ADMIN_PASS
# JWT_SECRET: generate + persist if blank (so it survives re-runs).
if [[ -z "${JWT_SECRET:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
JWT_SECRET=$(openssl rand -hex 32)
else
JWT_SECRET="theta-env-jwt-$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' ')"
fi
if grep -q '^JWT_SECRET=' .env; then
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=${JWT_SECRET}|" .env
else
printf 'JWT_SECRET=%s\n' "$JWT_SECRET" >> .env
fi
info "Generated + persisted JWT_SECRET into .env (save it — it signs all tokens)."
fi
# Default BOOTSTRAP_ADMIN_EMAIL if blank.
BOOTSTRAP_ADMIN_EMAIL="${BOOTSTRAP_ADMIN_EMAIL:-admin@${PROXY_HOST}}"
# Default LDAP_SERVICE_PASS if blank (random).
if [[ -z "${LDAP_SERVICE_PASS:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
LCD=$(openssl rand -hex 16)
else
LCD="svc-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' ')"
fi
LDAP_SERVICE_PASS="$LCD"
if grep -q '^LDAP_SERVICE_PASS=' .env; then
sed -i "s|^LDAP_SERVICE_PASS=.*|LDAP_SERVICE_PASS=${LDAP_SERVICE_PASS}|" .env
else
printf 'LDAP_SERVICE_PASS=%s\n' "$LDAP_SERVICE_PASS" >> .env
fi
info "Generated + persisted LDAP_SERVICE_PASS into .env."
fi
info "Stack config:"
info " Base DN: ${LDAP_BASE_DN}"
info " SSO host: https://${SSO_HOST}"
info " Proxy host: https://${PROXY_HOST}"
info " Admin uid: ${BOOTSTRAP_ADMIN_UID}"
# ── 2. Start SSO Manager, wait for health ─────────────────────────────────────
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build sso-manager
info "Waiting for sso-manager to be healthy..."
for i in $(seq 1 60); do
status=$("${COMPOSE[@]}" ps -o json sso-manager 2>/dev/null \
| grep -o '"Health":"healthy"' || true)
if [[ -n "$status" ]]; then info "sso-manager is healthy."; break; fi
# Fall back to probing /health directly (compose v1 lacks `ps -o json`).
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
info "sso-manager is healthy (probed /health)."; break
fi
if (( i == 60 )); then die "sso-manager did not become healthy in 60s. Check: ${COMPOSE[*]} logs sso-manager"; fi
sleep 2
done
# ── 3. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ──
# PROXY_ENV_EXISTS tells the bootstrap whether to rotate the client secret: if
# proxy.env already exists, keep the existing secret (the proxy can still read
# it); if not, rotate so a wiped-and-restored proxy gets a usable secret.
PROXY_ENV_EXISTS=0
[[ -f ./proxy.env ]] && PROXY_ENV_EXISTS=1
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e LDAP_BASE_DN="${LDAP_BASE_DN}" \
-e LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS}" \
-e BOOTSTRAP_ADMIN_UID="${BOOTSTRAP_ADMIN_UID}" \
-e BOOTSTRAP_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS}" \
-e BOOTSTRAP_ADMIN_EMAIL="${BOOTSTRAP_ADMIN_EMAIL}" \
-e LDAP_SERVICE_PASS="${LDAP_SERVICE_PASS}" \
-e SSO_HOST="${SSO_HOST}" \
-e PROXY_HOST="${PROXY_HOST}" \
-e PROXY_ENV_EXISTS="${PROXY_ENV_EXISTS}" \
sso-manager node /bootstrap/bootstrap.js) \
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}"
# Parse KEY=VALUE lines from stdout (bootstrap logs go to stderr, so this is clean).
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}"
[[ -n "$CLIENT_SECRET" ]] || die "bootstrap did not return CLIENT_SECRET:\n${BOOTSTRAP_OUT}"
# ── 4. Write ./proxy.env (the proxy's env_file) ───────────────────────────────
# All app_* so the proxy reads them via @simpleworkjs/conf (>=1.1.0) env overrides.
# Browser-facing endpoints use https://${SSO_HOST}; server-to-server
# token/userinfo use the internal http://sso-manager:3001 (no hairpin through the
# public TLS listener). LDAP over LDAPS on the docker network with the SSO's
# self-signed cert (rejectUnauthorized=false). adminGroups + adminUsers are JSON
# arrays (conf coerces via JSON.parse).
if [[ "$CLIENT_SECRET" == "__UNCHANGED__" ]]; then
if [[ -f ./proxy.env ]]; then
info "proxy.env exists and client unchanged — preserving existing proxy.env."
CLIENT_SECRET=$(grep -m1 '^app_oidc__clientSecret=' ./proxy.env | cut -d= -f2-)
[[ -n "$CLIENT_SECRET" ]] || die "proxy.env exists but has no app_oidc__clientSecret; delete it and re-run."
else
# Shouldn't happen (bootstrap only emits __UNCHANGED__ when proxy.env exists),
# but recover by rotating: re-run bootstrap with PROXY_ENV_EXISTS=0.
die "proxy.env missing but bootstrap said unchanged. Delete proxy.env if present and re-run."
fi
fi
info "Writing ./proxy.env (proxy app_* config)..."
cat > ./proxy.env << PROXYEOF
# Generated by setup.sh from .env + the bootstrap output. DO NOT COMMIT.
# The proxy reads these via @simpleworkjs/conf app_* env overrides.
# ── OIDC (browser-facing endpoints use the public SSO URL; token/userinfo use
# the internal docker-network URL so the proxy never hairpins through TLS).
app_oidc__issuer=https://${SSO_HOST}
app_oidc__authorizationEndpoint=https://${SSO_HOST}/oauth/authorize
app_oidc__endSessionEndpoint=https://${SSO_HOST}/oauth/logout
app_oidc__tokenEndpoint=http://sso-manager:3001/oauth/token
app_oidc__userinfoEndpoint=http://sso-manager:3001/oauth/userinfo
app_oidc__clientId=${CLIENT_ID}
app_oidc__clientSecret=${CLIENT_SECRET}
app_oidc__redirectUri=https://${PROXY_HOST}/api/auth/oidc/callback
app_oidc__enabled=true
# ── LDAP (direct bind over LDAPS on the docker network; self-signed cert).
app_ldap__url=ldaps://sso-manager:636
app_ldap__bindDN=cn=ldapclient,ou=people,${LDAP_BASE_DN}
app_ldap__bindPassword=${LDAP_SERVICE_PASS}
app_ldap__searchBase=ou=people,${LDAP_BASE_DN}
app_ldap__userFilter=(objectClass=posixAccount)
app_ldap__tlsOptions__rejectUnauthorized=false
# ── Auth (anti-lockout: the local proxyadmin2 user + SSO admin group).
app_auth__adminGroups=["app_sso_admin"]
app_auth__adminUsers=["proxyadmin2"]
PROXYEOF
chmod 600 ./proxy.env
if [[ "$ALREADY_CONFIGURED" == "1" ]]; then
info "Stack was already configured — proxy.env refreshed with current creds."
else
info "OAuth client registered + proxy.env written."
fi
# ── 5. Start the proxy, wait for health ───────────────────────────────────────
info "Building + starting proxy (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build proxy
info "Waiting for proxy to be healthy..."
for i in $(seq 1 60); do
if docker exec proxy curl -fsS http://localhost:3000/health >/dev/null 2>&1; then
info "proxy is healthy."; break
fi
if (( i == 60 )); then die "proxy did not become healthy in 60s. Check: ${COMPOSE[*]} logs proxy"; fi
sleep 2
done
# ── 6. Summary ───────────────────────────────────────────────────────────────
echo
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
echo
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
echo " First admin login:"
echo " user: ${BOOTSTRAP_ADMIN_UID}"
echo " pass: ${BOOTSTRAP_ADMIN_PASS}"
echo
echo " Next: add DNS records (or /etc/hosts) pointing ${SSO_HOST} and ${PROXY_HOST}"
echo " at this host, then open https://${SSO_HOST} and log in as the admin."
echo " The proxy auto-issues Let's Encrypt certs if port 80 is reachable;"
echo " otherwise it serves a self-signed fallback on the LAN."
echo
echo " Re-run ./setup.sh any time to converge the stack to .env (idempotent)."
+1
Submodule sso-manager-node added at fe9b7c168b