From 9fb240ff45f018cbd693752bbec12a9a9a95d538 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 11 Jul 2026 17:04:36 -0400 Subject: [PATCH] theta-env: unified SSO Manager + Proxy stack with one-command setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 64 ++++++++++ .gitignore | 7 ++ .gitmodules | 6 + README.md | 205 +++++++++++++++++++++++++++++++ bootstrap/bootstrap.js | 273 +++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 103 ++++++++++++++++ docs/_config.yml | 9 ++ docs/architecture.md | 129 +++++++++++++++++++ docs/index.md | 96 +++++++++++++++ docs/quickstart.md | 141 +++++++++++++++++++++ docs/standalone.md | 111 +++++++++++++++++ proxy | 1 + setup.sh | 239 ++++++++++++++++++++++++++++++++++++ sso-manager-node | 1 + 14 files changed, 1385 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 bootstrap/bootstrap.js create mode 100644 docker-compose.yml create mode 100644 docs/_config.yml create mode 100644 docs/architecture.md create mode 100644 docs/index.md create mode 100644 docs/quickstart.md create mode 100644 docs/standalone.md create mode 160000 proxy create mode 100755 setup.sh create mode 160000 sso-manager-node diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a29b0f5 --- /dev/null +++ b/.env.example @@ -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:// and its own +# management UI at https://. 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, 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= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da064ac --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..686b34a --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cefddd --- /dev/null +++ b/README.md @@ -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,`), + - 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://` — 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://` — 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://:636` as + `cn=admin,` (admin) or `cn=ldapclient,ou=people,` (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=` 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). \ No newline at end of file diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js new file mode 100644 index 0000000..4890822 --- /dev/null +++ b/bootstrap/bootstrap.js @@ -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); + } +})(); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01bcbb6 --- /dev/null +++ b/docker-compose.yml @@ -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://; 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: \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..b770f1b --- /dev/null +++ b/docs/_config.yml @@ -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 \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..28c3ab9 --- /dev/null +++ b/docs/architecture.md @@ -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,` (an + `organizationalRole` with a `{SSHA512}` password). The proxy binds as this + DN — not the admin DN. +3. **First admin user** — `ldapadd` `cn=,ou=people,` (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) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..78fbf24 --- /dev/null +++ b/docs/index.md @@ -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://` — log in as your first admin to manage + users, groups, and OAuth clients. Fronted by the proxy under TLS. +- **Proxy** at `https://` — add the Host records you want to protect + with OIDC login. +- **LDAPS** at `ldaps://: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. \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b426c8d --- /dev/null +++ b/docs/quickstart.md @@ -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://` 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://` (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://: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) \ No newline at end of file diff --git a/docs/standalone.md b/docs/standalone.md new file mode 100644 index 0000000..9eb8099 --- /dev/null +++ b/docs/standalone.md @@ -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://: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 </` +- 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) \ No newline at end of file diff --git a/proxy b/proxy new file mode 160000 index 0000000..94ad614 --- /dev/null +++ b/proxy @@ -0,0 +1 @@ +Subproject commit 94ad6143ccf0b80d632d9fcccffe0ae40730c622 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..405becb --- /dev/null +++ b/setup.sh @@ -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 && 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)." \ No newline at end of file diff --git a/sso-manager-node b/sso-manager-node new file mode 160000 index 0000000..fe9b7c1 --- /dev/null +++ b/sso-manager-node @@ -0,0 +1 @@ +Subproject commit fe9b7c168bd2dca39e3b7b06aafaa71bcad97653