diff --git a/.gitignore b/.gitignore index da064ac..b16be34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ -# Local deployment config — contains secrets (LDAP_ADMIN_PASS, JWT_SECRET, -# OAuth client secret, LDAP service password). Never commit. +# Local deployment config — contains secrets (LDAP admin password, JWT secret, +# OAuth client secret, LDAP service password, SMTP creds). Never commit. +# ./config/ holds the live sso-secrets.js + proxy-secrets.js (generated by +# setup.sh); committed examples live in config.example/. +config/ +backups/ + +# Legacy .env / proxy.env (no longer used — config is in ./config/). Still +# ignored in case a migrated deployment hasn't deleted them yet. .env proxy.env +# Backup artifacts (hold secrets — the whole user directory + Redis dumps) +*.rdb +*.ldif + # Docker Compose runtime artifacts *.log \ No newline at end of file diff --git a/README.md b/README.md index 5684a7e..759b91f 100644 --- a/README.md +++ b/README.md @@ -109,49 +109,58 @@ standalone (`docker-compose`) both work. ```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 # generates ./config/ the first time — edit it, then re-run +./setup.sh # builds + bootstraps + starts the stack ``` -`./setup.sh` is idempotent — re-run it any time to converge the stack to your -`.env`. It: +The first `./setup.sh` generates `./config/sso-secrets.js` + +`./config/proxy-secrets.js` with random secrets and **exits**, telling you to +edit them. Set at least `stack.ssoHost`, `stack.proxyHost`, `stack.ldapBaseDn`, +and `bootstrap.adminUid`/`adminPass` in `sso-secrets.js`, then re-run. The second +run builds and brings up the stack. -1. Builds + starts the SSO Manager container, waits for it to be healthy. -2. Runs the bootstrap (`bootstrap/bootstrap.js`) **inside** the SSO container, +`./setup.sh` is idempotent — re-run it any time to converge the stack to +`./config/`. It: + +1. Snapshots state to `./backups//` before rebuilding (config + LDAP + + both Redis) — a no-op on the very first run. +2. Builds + starts the SSO Manager container, waits for it to be healthy. +3. 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. + - registers the proxy as an OIDC client in the SSO and **writes the generated + client id + secret back into `./config/proxy-secrets.js`**. 4. Builds + starts the proxy container, waits for it to be healthy. 5. Prints your first admin login + the public URLs. -### `.env` — the values you must set +### Configuration — `./config/` (no `.env` files) -Copy `.env.example` to `.env` and at minimum set: +All config and secrets live in a bind-mounted `./config/` directory (gitignored), +read by each app's `@simpleworkjs/conf` from a symlinked `secrets.js`: -| 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. | +- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password, + user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus + orchestrator-only `stack` (hostnames, base DN), `bootstrap` (first admin), + and `serviceAccountPass` (the proxy's LDAP bind password). +- **`./config/proxy-secrets.js`** — proxy config: `oidc` (endpoints, + `clientId`/`clientSecret` — filled in by the bootstrap), `ldap` (bind creds, + same `serviceAccountPass`), `auth` (admin groups/users). -> **Values with spaces:** quote them, e.g. `ORG_NAME="My Org"` or -> `SMTP_FROM="Theta SSO "`. Quotes are optional but -> recommended anywhere a value contains spaces — `setup.sh` and `docker -> compose` both strip a single pair of matching outer quotes. +`./setup.sh` generates both on first run with random secrets. There is **no +`.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates +port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the +command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the +full annotated shape, and each submodule's `secrets.js.example`. -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. +> **Migrating from an older `.env`-based deployment?** If `.env` and/or +> `proxy.env` exist when you first run `./setup.sh`, it migrates them into +> `./config/` **preserving your existing secrets** (LDAP admin pass, JWT, OAuth +> client creds, service pass) so your running deployment keeps its directory, +> tokens, and OAuth client. Afterwards `.env`/`proxy.env` are dead weight — +> delete them. --- @@ -211,23 +220,93 @@ slapd runs with `-d 0` and logs to stderr, so LDAP output is already in SSO and query the directory directly: ```bash +# Your base DN lives in ./config/sso-secrets.js (stack.ldapBaseDn). docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \ - -D "cn=admin,${LDAP_BASE_DN}" -W -b "${LDAP_BASE_DN}" + -D "cn=admin," -W -b "" ``` --- -## Backups +## Backups and restore -The directory lives in the `ldap-data` Docker volume. Back it up with `slapcat` -(the portable LDIF export — survives OpenLDAP version upgrades): +`./setup.sh` automatically snapshots state to `./backups//` **before +every rebuild** and keeps the last `BACKUP_KEEP` (default 5; e.g. +`BACKUP_KEEP=10 ./setup.sh`). Each snapshot has `config/` (your secrets), +`ldap.ldif` (the directory), and `sso-manager.rdb` + `proxy.rdb` (Redis). Back +the `./backups/` directory **off the host** — it holds secrets and the whole +user directory. + +### What lives where + +| State | Location | Persisted across rebuild? | +|-------|----------|---------------------------| +| LDAP directory (users, groups, policies) | `ldap-data` volume | yes (volume) | +| SSO Redis (OAuth clients, tokens) | `sso-data` volume | yes (AOF + RDB) | +| Proxy Redis (Host records, perms, DNS creds, LE certs) | `proxy-data` volume | yes (AOF + RDB) | +| Secrets (LDAP admin pass, JWT, OAuth client, service pass) | `./config/` | your responsibility — back up off-host | + +### Manual backup ```bash -docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "$LDAP_BASE_DN" > backup.ldif +# LDAP (while slapd is running) +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "$(docker compose exec -T sso-manager node -e 'console.log((require("/config/sso-secrets.js").stack||{}).ldapBaseDn)')" > ldap.ldif + +# Redis — hot snapshot each service +docker compose exec sso-manager redis-cli BGSAVE +docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb +docker compose exec proxy redis-cli BGSAVE +docker compose cp proxy:/data/dump.rdb proxy.rdb + +# Secrets +cp -a ./config config-backup && chmod 700 config-backup ``` -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`. +### Restore — full disaster recovery + +```bash +# 1. Secrets +cp -a backups//config ./config && chmod 700 ./config +./setup.sh # fresh empty volumes +docker compose stop sso-manager + +# 2. LDAP — the SSO uses a static slapd.conf (-f, not cn=config -F), so use slapadd -f +docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ + 'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \ + < backups//ldap.ldif +docker compose start sso-manager + +# 3. Redis — delete the AOF first (see note), then load the RDB +for svc in sso-manager proxy; do + docker compose stop "$svc" + docker compose run --rm --no-deps --entrypoint sh "$svc" -c \ + 'rm -f /data/appendonly.aof /data/appendonly.aof.*' + docker compose cp "backups//${svc}.rdb" "${svc}:/data/dump.rdb" + docker compose start "$svc" +done +``` + +> **AOF vs RDB (important):** with `--appendonly yes`, Redis loads +> `appendonly.aof` on startup and **ignores** `dump.rdb` if the AOF exists. To +> restore from an RDB snapshot you **must delete the AOF first** (step 3 does +> this); Redis then loads the RDB and writes a fresh AOF. Verify after restoring: +> `docker compose exec sso-manager redis-cli DBSIZE`, +> `docker compose exec proxy redis-cli DBSIZE`, +> `docker compose exec sso-manager ldapsearch -x -b ""`. + +Restore **Redis only** = step 3. Restore **LDAP only** = step 2. + +### Upgrades + +```bash +git pull --ff-only +./setup.sh # snapshots, then rebuilds — volumes keep LDAP + Redis state +``` +LDAP, both Redis stores, and the auto-ssl Let's Encrypt certs (in proxy Redis) +all survive the rebuild because they live on named volumes, not in the images. +Note: re-running bootstrap resets the bootstrap-admin and service-account +passwords to the `./config/` values; non-bootstrap OAuth clients live in SSO +Redis and are preserved by the volume. --- @@ -238,15 +317,16 @@ 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 + mkdir -p config && cp secrets.js.example config/sso-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`): +- **Proxy alone** (pointing at any external SSO + LDAP via a mounted + `secrets.js`): ```bash cd proxy + mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it docker compose up -d --build ``` See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md). @@ -260,17 +340,23 @@ composition (one compose file + one bootstrap script). `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. +built-ins (`child_process`, `crypto`, `fs`) + global `fetch`. It reads its inputs +from the bind-mounted `./config/sso-secrets.js` + `proxy-secrets.js` (not from +env). LDAP operations use the `openldap-clients` binaries +(`ldapadd`/`ldapsearch`/`ldapmodify`) with explicit admin creds from the config; +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). +It's idempotent: re-running converges to your `./config/` values. The LDAP +service account + admin passwords are reset to the config on each run; the OAuth +client is created if missing. If `proxy-secrets.js` already holds a +`clientId`+`clientSecret` matching an existing client, they are kept (the proxy +keeps working); otherwise a new client is created (or the secret rotated if the +client exists but the file has no usable secret) and the creds are written back +into `proxy-secrets.js` (the SSO mounts `./config` read-write for this; the proxy +mounts it read-only). Passwords are stored as `{SSHA512}` (the SSO's `hashPasswordSSHA512`, replicated exactly in the bootstrap) so the SSO can verify them on bind. @@ -283,20 +369,22 @@ exactly in the bootstrap) so the SSO can verify them on bind. (`3001`) and the proxy mgmt UI (`3000`) default to `0.0.0.0` for first-run convenience, so they're reachable on your LAN (they're login-protected, but it widens the attack surface). The proxy fronts both under TLS in normal - use, so set `SSO_BIND=127.0.0.1` and `MGMT_BIND=127.0.0.1` in `.env` to lock - them to the host once you're up and running. LDAPS (`636`) is the only LDAP - listener that should cross the network. -2. **Persist + protect `.env` and `proxy.env`.** They hold `LDAP_ADMIN_PASS`, - `JWT_SECRET`, the LDAP service password, and the OAuth client secret. - `setup.sh` writes `proxy.env` mode `0600`; both are in `.gitignore`. + use, so set `SSO_BIND=127.0.0.1` and `MGMT_BIND=127.0.0.1` to lock them to the + host once you're up and running (override on the command line, e.g. + `SSO_BIND=127.0.0.1 MGMT_BIND=127.0.0.1 ./setup.sh`). LDAPS (`636`) is the + only LDAP listener that should cross the network. +2. **Protect `./config/`.** It holds the LDAP admin password, JWT secret, LDAP + service password, and OAuth client secret. `setup.sh` writes the files mode + `0600` and the dir `0700`; the directory is in `.gitignore`. Back it up + off-host (see *Backups and restore*). 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`. + `ldap.tlsOptions.rejectUnauthorized=false` (in `proxy-secrets.js`). For strict + trust, mount the SSO's cert (`ldap-certs` volume) into the proxy and set + `ldap.tlsOptions.ca=` in `./config/proxy-secrets.js`. 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`. + the `./config/` values.** 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.adminPass`. 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. @@ -307,15 +395,19 @@ exactly in the bootstrap) so the SSO can verify them on bind. ``` 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 +├── config.example/ # committed annotated config templates (copy to ./config/) +├── docker-compose.yml # sso-manager + proxy on one bridge net +├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups) ├── bootstrap/ │ └── bootstrap.js # runs in the sso-manager container -├── sso-manager-node/ # git submodule -└── proxy/ # git submodule +├── sso-manager-node/ # git submodule +└── proxy/ # git submodule ``` +`./setup.sh` generates the gitignored `./config/` (`sso-secrets.js` + +`proxy-secrets.js`) on first run and snapshots to the gitignored `./backups/` +before each rebuild. + `./setup.sh` updates both submodules to the latest of their tracked remote branch before building, so each run builds current upstream — no manual `git submodule update --remote` needed. To lock to the pinned commits (offline diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index 71693ca..a9a15b5 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -1,54 +1,73 @@ #!/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: + * proxy into a (fresh or existing) 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). + * crypto, fs) + 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; 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). + * Config is read from the bind-mounted ./config/ directory (at /config in the + * container), NOT from environment variables: + * /config/sso-secrets.js — directory root creds, first admin, service + * account pass, public hostnames, base DN + * /config/proxy-secrets.js — the proxy's OIDC client creds (clientId / + * clientSecret). The SSO *generates* these on + * client create, so this script writes them back + * into the file (the sso-manager mounts ./config + * read-write for this purpose). * - * 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 + * Idempotent: re-running converges to the ./config values. The LDAP service + * account + admin passwords are reset to the file values on each run; the + * OAuth client is created if missing. If proxy-secrets.js already holds a + * clientId+clientSecret matching an existing client, they are kept (the proxy + * keeps working). If the client is missing but the file has creds, a new client + * is created and the file is updated. The secret is rotated only when a client + * exists but the file has no usable secret to recover. * * Output (stdout, KEY=VALUE for setup.sh to parse): CLIENT_ID, CLIENT_SECRET, - * and ALREADY_CONFIGURED. Progress logs go to stderr. + * ALREADY_CONFIGURED. Progress logs go to stderr. */ 'use strict'; const { execFileSync } = require('child_process'); const crypto = require('crypto'); +const fs = require('fs'); -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'; +// ── Read config from the mounted ./config/ (NOT env) ───────────────────────── +const sso = require('/config/sso-secrets.js'); +const proxy = require('/config/proxy-secrets.js'); -const ADMIN_UID = process.env.BOOTSTRAP_ADMIN_UID || 'admin'; +const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com'; +const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin'; +const BIND_DN = `cn=admin,${BASE_DN}`; +const LDAP_URL = 'ldap://localhost:389'; + +const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin'; // The first admin *user's* password (cn=,ou=people,). Distinct from -// ADMIN_PASS above, which is the LDAP *root* (cn=admin,) bind password -// from LDAP_ADMIN_PASS — two different accounts, two different secrets. -const ADMIN_USER_PASS = process.env.BOOTSTRAP_ADMIN_PASS || 'admin'; -const ADMIN_EMAIL = process.env.BOOTSTRAP_ADMIN_EMAIL || ''; -const SVC_PASS = process.env.LDAP_SERVICE_PASS || 'service'; +// ADMIN_PASS above, which is the LDAP *root* (cn=admin,) bind password — +// two different accounts, two different secrets. +const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin'; +const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || ''; +const SVC_PASS = sso.serviceAccountPass || '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 SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com'; +const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com'; + +// OAuth client creds the proxy will use. The SSO generates these on create; +// proxy-secrets.js starts with placeholders, and this script writes the real +// values back (writeProxyCreds below). +const EXISTING_ID = (proxy.oidc && proxy.oidc.clientId) || ''; +const EXISTING_SECRET = (proxy.oidc && proxy.oidc.clientSecret) || ''; +const PLACEHOLDER = /^set-me$|^$/; +const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET + && !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET); const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`; const SSO_INTERNAL = 'http://localhost:3001'; @@ -104,7 +123,7 @@ function ldapModify(ldif) { function ensureServiceAccount() { const pw = hashPasswordSSHA512(SVC_PASS); if (entryExists(SVC_DN)) { - log(`Service account ${SVC_DN} exists — resetting password to .env`); + log(`Service account ${SVC_DN} exists — resetting password to ./config`); const r = ldapModify([ `dn: ${SVC_DN}`, 'changetype: modify', @@ -132,7 +151,7 @@ function ensureServiceAccount() { function ensureAdmin() { const pw = hashPasswordSSHA512(ADMIN_USER_PASS); if (entryExists(ADMIN_DN)) { - log(`Admin ${ADMIN_DN} exists — resetting password to .env and ensuring groups`); + log(`Admin ${ADMIN_DN} exists — resetting password to ./config and ensuring groups`); ldapModify([ `dn: ${ADMIN_DN}`, 'changetype: modify', @@ -194,14 +213,13 @@ async function login() { } // ── 4. OAuth client for the proxy ─────────────────────────────────────────── -async function findClient(token) { +async function listClients(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; + return (data && data.results) || []; } async function createClient(token) { @@ -243,6 +261,36 @@ async function rotateClient(token, id) { return { id, secret: data.client_secret }; } +// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy +// (which reads that file) can use them. Only the clientId/clientSecret lines +// are touched; the rest of the file (operator edits, comments) is preserved. +// Handles single- or double-quoted values. Creds are UUIDs — no quotes in them. +function writeProxyCreds(id, secret) { + const path = '/config/proxy-secrets.js'; + let src; + try { + src = fs.readFileSync(path, 'utf8'); + } catch (e) { + log(`WARNING: cannot read ${path} to write creds back (${e.message}) — update proxy-secrets.js manually with clientId=${id}`); + return false; + } + const before = src; + src = src.replace(/(clientId:\s*)(['"])[^'"]*\2/, `$1$2${id}$2`); + src = src.replace(/(clientSecret:\s*)(['"])[^'"]*\2/, `$1$2${secret}$2`); + if (src === before) { + log(`WARNING: could not locate clientId/clientSecret in ${path} — update it manually with clientId=${id} clientSecret=${secret}`); + return false; + } + try { + fs.writeFileSync(path, src); + log(`Wrote OAuth client creds into ${path}`); + return true; + } catch (e) { + log(`WARNING: cannot write ${path} (${e.message}) — is ./config mounted read-write on sso-manager? Update proxy-secrets.js manually with clientId=${id} clientSecret=${secret}`); + return false; + } +} + (async function main() { try { log(`Base DN: ${BASE_DN}`); @@ -250,20 +298,33 @@ async function rotateClient(token, id) { ensureAdmin(); const token = await login(); - const existing = await findClient(token); - if (!existing) { - const { id, secret } = await createClient(token); + const list = await listClients(token); + // Find the proxy's client: by id if we have usable creds, else by name. + let client = null; + if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID); + if (!client) client = list.find((c) => c.name === CLIENT_NAME); + + if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) { + // File creds match an existing client — trust the file's secret + // (it's bcrypt-hashed server-side, so we can't verify, but the proxy + // was working with it). Keep the file as-is. + log(`OAuth client ${CLIENT_NAME} (${EXISTING_ID}) exists and proxy-secrets.js has its creds — keeping`); + out('CLIENT_ID', EXISTING_ID); + out('CLIENT_SECRET', EXISTING_SECRET); + out('ALREADY_CONFIGURED', '1'); + } else if (client) { + // Client exists but the file has no recoverable secret for it — rotate + // so the proxy gets a fresh secret it can actually read, then write back. + log(`OAuth client ${CLIENT_NAME} (${client.client_id}) exists but proxy-secrets.js has no usable secret — rotating + writing back`); + const { id, secret } = await rotateClient(token, client.client_id); + writeProxyCreds(id, secret); 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); + // No client yet — create one and write the generated creds back. + const { id, secret } = await createClient(token); + writeProxyCreds(id, secret); out('CLIENT_ID', id); out('CLIENT_SECRET', secret); out('ALREADY_CONFIGURED', '0'); diff --git a/config.example/proxy-secrets.js.example b/config.example/proxy-secrets.js.example new file mode 100644 index 0000000..7e4c92d --- /dev/null +++ b/config.example/proxy-secrets.js.example @@ -0,0 +1,49 @@ +'use strict'; +// Example proxy secrets for the theta-env unified stack. Copy to +// ./config/proxy-secrets.js (NOT this file — ./config/ is gitignored) and edit. +// `./setup.sh` generates ./config/proxy-secrets.js for you on first run and the +// bootstrap writes the OAuth client clientId/clientSecret back into it; this +// file documents the shape for manual editing / reference. +// +// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh +// symlinks it to /app/conf/secrets.js). Never commit ./config/. + +module.exports = { + oidc: { + enabled: true, + issuer: 'https://sso.example.com', + authorizationEndpoint: 'https://sso.example.com/oauth/authorize', + // token/userinfo use the internal docker-network URL (no TLS hairpin): + tokenEndpoint: 'http://sso-manager:3001/oauth/token', + userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo', + endSessionEndpoint: 'https://sso.example.com/oauth/logout', + clientId: 'FILLED-IN-BY-BOOTSTRAP', // leave as-is; bootstrap sets it + clientSecret: 'FILLED-IN-BY-BOOTSTRAP', // leave as-is; bootstrap sets it + redirectUri: 'https://proxy.example.com/api/auth/oidc/callback', + scopes: ['openid', 'profile', 'email', 'groups'], + groupsClaim: 'groups', + usernameClaim: 'preferred_username', + }, + ldap: { + // LDAPS over the docker network; the SSO's self-signed cert is trusted + // via tlsOptions.rejectUnauthorized:false. + url: 'ldaps://sso-manager:636', + bindDN: 'cn=ldapclient,ou=people,dc=example,dc=com', + // MUST equal serviceAccountPass in sso-secrets.js (the proxy binds as + // that service account). setup.sh keeps them in sync on generation. + bindPassword: 'CHANGE-ME', + searchBase: 'ou=people,dc=example,dc=com', + userFilter: '(objectClass=posixAccount)', + userNameAttribute: 'uid', + tlsOptions: { rejectUnauthorized: false }, + }, + auth: { + adminGroups: ['app_sso_admin'], // SSO group -> global proxy admin + adminUsers: ['proxyadmin2'], // local anti-lockout admin + groupRoleMap: {}, + }, + stack: { + ssoHost: 'sso.example.com', + proxyHost: 'proxy.example.com', + }, +}; \ No newline at end of file diff --git a/config.example/sso-secrets.js.example b/config.example/sso-secrets.js.example new file mode 100644 index 0000000..060ae10 --- /dev/null +++ b/config.example/sso-secrets.js.example @@ -0,0 +1,45 @@ +'use strict'; +// Example SSO secrets for the theta-env unified stack. Copy to +// ./config/sso-secrets.js (NOT this file — ./config/ is gitignored) and edit. +// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file +// documents the shape for manual editing / reference. +// +// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh symlinks +// it to /app/conf/secrets.js). The app ignores the extra stack/bootstrap/ +// serviceAccountPass keys (read by the orchestrator). Back this up off-host — +// it holds all SSO secrets. Never commit ./config/. + +module.exports = { + name: 'SSO Manager', // shown in UI + outbound email + ldap: { + url: 'ldap://localhost:389', // the bundled slapd (in-container) + bindDN: 'cn=admin,dc=example,dc=com', // slapd root DN + bindPassword: 'CHANGE-ME', // slapd root + app bind password + userBase: 'ou=people,dc=example,dc=com', + groupBase: 'ou=groups,dc=example,dc=com', + }, + smtp: { // optional; leave host '' to skip + host: '', port: 587, secure: false, + user: '', pass: '', from: '', + }, + oauth: { + issuer: 'https://sso.example.com', // browser-facing SSO URL + jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret + token_lifetime: { access_token: 3600, refresh_token: 2592000 }, + }, + + // ── Orchestrator-only (ignored by the app; read by setup.sh + bootstrap) ── + stack: { + ldapBaseDn: 'dc=example,dc=com', // slapd suffix (drives seed OUs) + ldapDomain: 'example.com', // default cert CN + issuer host + ldapCertCn: '', // cert CN; '' -> defaults to ldapDomain + ssoHost: 'sso.example.com', // public SSO hostname + proxyHost: 'proxy.example.com', // public proxy hostname + }, + bootstrap: { + adminUid: 'admin', // first SSO admin username + adminPass: 'CHANGE-ME', // first SSO admin password + adminEmail: 'admin@proxy.example.com', // first SSO admin email + }, + serviceAccountPass: 'CHANGE-ME', // LDAP password the proxy binds with +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d7e4e7c..c100bbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,17 +2,27 @@ # # 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. +# 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). +# Config + secrets live in bind-mounted ./config/ (gitignored): +# ./config/sso-secrets.js — SSO app + orchestrator config +# ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config +# Each app's entrypoint symlinks its file into /app/conf/secrets.js so +# @simpleworkjs/conf reads it. No app_* env is passed (app_* env would override +# secrets.js). The sso-manager mounts ./config read-write so the bootstrap can +# write the generated OAuth client creds back into proxy-secrets.js; the proxy +# mounts it read-only. +# +# Compose only interpolates the port defaults below — there is no .env file. +# First-run wiring (LDAP service account, first admin, OAuth client) is +# automated by ./setup.sh, which runs bootstrap/bootstrap.js inside the +# sso-manager container. services: sso-manager: @@ -24,33 +34,32 @@ services: networks: [theta-net] ports: # SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so - # the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 in - # .env to lock it to localhost once the proxy fronts it at https://. + # the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to + # lock it to localhost once the proxy fronts it at https://. - "${SSO_BIND:-0.0.0.0}:${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:-} + # Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see + # volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app + # reads that are not part of its conf tree. - NODE_ENV=production - NODE_PORT=3001 volumes: + # Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap + # can write the generated OAuth client creds into proxy-secrets.js. The + # entrypoint symlinks /config/sso-secrets.js -> /app/conf/secrets.js. + - ./config:/config + # Persist the LDAP database across container recreation. - ldap-data:/var/lib/ldap + # Persist the auto-generated self-signed TLS cert so clients don't have to + # re-trust it on every rebuild. - ldap-certs:/etc/openldap/certs + # Persist Redis (AOF + RDB) so OAuth clients, tokens, and other Redis state + # survive container recreation. + - sso-data:/data # Bind-mount the bootstrap script so `docker compose exec sso-manager node # /bootstrap/bootstrap.js` can run it (read-only). - ./bootstrap:/bootstrap:ro @@ -77,13 +86,21 @@ services: - "${HTTPS_ALT_PORT:-4443}:4443" # Management UI/API. Bind address is configurable via MGMT_BIND (default # 0.0.0.0 so it's reachable on the LAN during setup). Set MGMT_BIND=127.0.0.1 - # in .env to lock it to localhost once the proxy fronts it under TLS. + # to lock it to localhost once the proxy fronts it under TLS. - "${MGMT_BIND:-0.0.0.0}:${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 + environment: + # oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes), + # not from env. NODE_ENV/NODE_PORT are process env the app reads directly. + - NODE_ENV=production + - NODE_PORT=3000 volumes: + # Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy + # only reads it; the sso-manager bootstrap writes the OAuth creds. The + # entrypoint symlinks /config/proxy-secrets.js -> /app/conf/secrets.js. + - ./config:/config:ro + # Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local + # users, AND the auto-ssl Let's Encrypt certs survive container recreation. + - proxy-data:/data - proxy-cache:/var/cache/nginx/proxy - proxy-logs:/var/log/nginx healthcheck: @@ -100,5 +117,7 @@ networks: volumes: ldap-data: ldap-certs: + sso-data: + proxy-data: proxy-cache: proxy-logs: \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 28c3ab9..48a75f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,9 @@ redis instances is the no-source-patch path and is fine at this scale. `./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`: +built-ins (`child_process`, `crypto`, `fs`) + global `fetch`, and it reads its +inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets.js` +(not from env): 1. **Build + start sso-manager**, wait for `/health`. 2. **LDAP service account** — `ldapadd` `cn=ldapclient,ou=people,` (an @@ -86,44 +88,70 @@ built-ins (`child_process`, `crypto`) + global `fetch`: 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`. + by `app_sso_oauth_admin`, satisfied by step 3). The SSO **generates** the + `client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the + bootstrap writes the generated creds **back into `./config/proxy-secrets.js`** + (the sso-manager mounts `./config` read-write for this; the proxy mounts it + read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret` + matching an existing client, they are kept; if the client exists but the file + has no usable secret, the secret is rotated and written back. +6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint symlinks + `./config/proxy-secrets.js` to `/app/conf/secrets.js`, so `@simpleworkjs/conf` + (≥1.1.0) reads the OAuth creds + LDAP bind creds from the file. `setup.sh` then prints the first-admin login + the public URLs. +### How config reaches the apps (no `.env`) + +All config and secrets live in `./config/` (gitignored, bind-mounted). Each +entrypoint symlinks its file to `/app/conf/secrets.js` early, before the app +starts: + +``` +./config/sso-secrets.js -> sso-manager:/app/conf/secrets.js (./config RW) +./config/proxy-secrets.js -> proxy:/app/conf/secrets.js (./config RO) +``` + +`@simpleworkjs/conf` loads `conf/base.js → .js → conf/secrets.js → app_* +env`, where **env beats `secrets.js`**. So compose passes **no `app_*` env vars** +(only `NODE_ENV`, `NODE_PORT`) — that makes `secrets.js` authoritative. The SSO +entrypoint reads the few values it needs at startup (LDAP base DN, admin +password, JWT secret, cert CN) from `secrets.js` via an in-container `node` call. + ### Why not `require` the SSO's internal models? A `docker compose exec` process reads `conf/base.js` defaults (the docker-exec -env doesn't carry the entrypoint's exported `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. +env doesn't carry the entrypoint's exported vars), so the SSO's models would +bind the wrong LDAP DN. Using the `openldap-clients` binaries with explicit +admin creds from `./config/sso-secrets.js` sidesteps that entirely, and going +through the HTTP API for the OAuth client validates the whole admin login path +end-to-end. ## Idempotency -Re-running `./setup.sh` converges to `.env`: +Re-running `./setup.sh` converges to `./config/`: -- The LDAP service account + admin passwords are **reset to `.env`**. +- The LDAP service account + admin passwords are **reset to `./config/`**. - Group membership is ensured (add is a no-op if already a member). -- The OAuth client is left alone if `proxy.env` exists, rotated if not. +- The OAuth client is kept if `proxy-secrets.js` already holds its creds; + created or rotated otherwise, and the new creds written back. -So `setup.sh` is safe to re-run after editing `.env`, after a `docker compose -down`, or after restoring from backup. +So `setup.sh` is safe to re-run after editing `./config/`, after a `docker +compose down`, or after restoring from backup. -## Backups +## Backups and restore + +`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to +`./backups//` before each rebuild (keeps the last `BACKUP_KEEP`, +default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`) +and survives recreation; `down -v` wipes them. Redis is persisted with AOF + +RDB on those volumes. For the full manual-backup + restore runbook (full / +Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and +restore* section of the [README](https://github.com/theta42/theta-env#backups-and-restore). +Quick LDAP backup: ```bash -docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "$LDAP_BASE_DN" > backup.ldif +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "" > 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 index 78fbf24..a97ab2b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,17 +19,16 @@ wires them together and automates the first-run glue. ```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 +./setup.sh # generates ./config/ the first time — edit it, then re-run +./setup.sh # builds + bootstraps + starts the stack ``` You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any -time to converge the stack to your `.env`. +time to converge the stack to `./config/`. -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. +See the [Quickstart Guide](quickstart.html) for a walkthrough of `./config/` 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 @@ -40,18 +39,23 @@ on its own. - **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 +## The `./config/` values you must set -| Key | What it is | +All config and secrets live in `./config/sso-secrets.js` + +`./config/proxy-secrets.js` (gitignored), generated by the first `./setup.sh`. +There is **no `.env`**. Set at least these in `./config/sso-secrets.js`: + +| Key (in `sso-secrets.js`) | 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. | +| `stack.ldapBaseDn` | Directory base, e.g. `dc=lab,dc=local`. | +| `ldap.bindPassword` | LDAP root password (generated). **Back it up.** | +| `oauth.jwtSecret` | Signs the SSO's tokens (generated). **Back it up.** | +| `stack.ssoHost` | Public hostname the proxy serves the SSO UI at. | +| `stack.proxyHost` | Public hostname the proxy serves its own mgmt UI at. | +| `bootstrap.adminUid` / `bootstrap.adminPass` | Your first admin login. | -See `.env.example` for the full list (SMTP, port overrides, LDAP cert CN, …). +See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy +OIDC/LDAP/auth, …). ## Architecture @@ -81,7 +85,7 @@ diagram + the first-run bootstrap flow. ## Documentation -- [Quickstart Guide](quickstart.html) — full walkthrough of `.env` + `setup.sh`. +- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `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. diff --git a/docs/quickstart.md b/docs/quickstart.md index b426c8d..cb9acf0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -11,9 +11,9 @@ title: Quickstart - 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`. +- Two hostnames that resolve to the host: one for the SSO UI (your `stack.ssoHost`), + one for the proxy mgmt UI (your `stack.proxyHost`). 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). @@ -32,28 +32,32 @@ step. If you forgot it: git submodule update --init --recursive ``` -## 2. Configure `.env` +## 2. Configure `./config/` ```bash -cp .env.example .env +./setup.sh # generates ./config/ with random secrets, then exits ``` -Edit `.env`. The **required** values: +The first `./setup.sh` generates `./config/sso-secrets.js` + +`./config/proxy-secrets.js` and **exits**, telling you to edit. Edit +`./config/sso-secrets.js` and at minimum set: -| Key | Example | Notes | +| Key (in `sso-secrets.js`) | 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 | +| `stack.ldapBaseDn` | `dc=lab,dc=local` | your directory base | +| `stack.ssoHost` | `sso.lab.local` | hostname the proxy serves the SSO UI at | +| `stack.proxyHost` | `proxy.lab.local` | hostname the proxy serves its own UI at | +| `bootstrap.adminUid` | `admin` | your first admin login | +| `bootstrap.adminPass` | `...` | 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. +Random secrets (`ldap.bindPassword`, `oauth.jwtSecret`, `serviceAccountPass`) +are generated for you — change them in the file if you like. Optional: +`bootstrap.adminEmail`, `smtp.*`, `stack.ldapCertCn`. See `config.example/` for +the full annotated shape, and each submodule's `secrets.js.example`. + +> **Migrating from an older `.env`-based deployment?** If `.env`/`proxy.env` +> exist, `./setup.sh` migrates them into `./config/` preserving your existing +> secrets — no need to reconfigure. ## 3. Run @@ -63,24 +67,22 @@ overrides (`SSO_PORT`, `LDAPS_PORT`, `HTTP_PORT`, `HTTPS_PORT`, What happens: -1. Validates `.env` (copies from `.env.example` if missing, then exits so you - can edit it). +1. Snapshots state to `./backups//` before rebuilding (a no-op on the + very first run). 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. + service account, your first admin, and the proxy's OAuth client, and writes + the generated client id + secret into `./config/proxy-secrets.js`. +4. Builds + starts **proxy**, waits for `/health`. +5. 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: +`stack.ssoHost` and `stack.proxyHost` (from `./config/sso-secrets.js`) 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 @@ -92,7 +94,7 @@ 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, +(`bootstrap.adminUid` / `bootstrap.adminPass`). From there you can add users, groups, and OAuth clients. The proxy mgmt UI is at `https://` (same admin SSO login protects @@ -103,10 +105,11 @@ First-run fallbacks (if DNS/TLS isn't ready yet): SSO UI at ## 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). +`./setup.sh` is **idempotent** — safe to re-run after editing `./config/`, after +a `docker compose down`, or after restoring from backup. It snapshots state, +then converges the stack to your `./config/` values (LDAP service account + admin +passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js` +already holds its creds). ## Direct LDAP for legacy apps @@ -121,16 +124,20 @@ ldapsearch -x -H ldaps://:636 \ Use the `cn=ldapclient` service account (read-only, the bootstrap created it) or the admin DN. Use LDAPS (636), not plain LDAP. -## Backups +## Backups and restore + +`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to `./backups//` +before each rebuild (keeps the last `BACKUP_KEEP`, default 5). For manual +backups and the full restore runbook (full / Redis-only / LDAP-only, with the +AOF-vs-RDB note), see the *Backups and restore* section of the +[README](https://github.com/theta42/theta-env#backups-and-restore). Quick LDAP +backup: ```bash docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ - -b "$LDAP_BASE_DN" > backup-$(date +%F).ldif + -b "" > 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. diff --git a/docs/standalone.md b/docs/standalone.md index 9eb8099..25a8273 100644 --- a/docs/standalone.md +++ b/docs/standalone.md @@ -18,22 +18,22 @@ 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 +mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it docker compose up -d --build ``` +The entrypoint symlinks `config/sso-secrets.js` to `nodejs/conf/secrets.js` so +`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and +the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass +**no `app_*` env** — env beats `secrets.js`, so `app_*` would silently override +your file. + - 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: +Requires `@simpleworkjs/conf` >= 1.1.0. Full reference: [SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html). ### Bare metal @@ -53,26 +53,16 @@ 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` @@ -97,12 +87,13 @@ 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 +2. Set the SSO's `oauth.issuer` (in its `secrets.js`) 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. + the proxy's callback; put the resulting `clientId`/`clientSecret` in the + proxy's `secrets.js`. +4. Point the proxy's `ldap.url` at the SSO's LDAPS + create a dedicated + `cn=ldapclient` service account; set the same password as `bindPassword`. 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 diff --git a/proxy b/proxy index 3f178b0..8e78604 160000 --- a/proxy +++ b/proxy @@ -1 +1 @@ -Subproject commit 3f178b038c4c1fc950755d9bd3f2820d849e8702 +Subproject commit 8e78604a37cc473de39ef0690aec471ddb514142 diff --git a/setup.sh b/setup.sh index c70a4d7..44e2883 100755 --- a/setup.sh +++ b/setup.sh @@ -3,39 +3,69 @@ # 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 +# ./setup.sh # generates ./config/ the first time — edit it, re-run +# ./setup.sh # builds + bootstraps + starts the stack # -# 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. +# Idempotent: safe to re-run. It manages config in a bind-mounted ./config/ +# directory (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots +# state before rebuild, (re)starts the SSO Manager, runs the bootstrap (which +# converges the LDAP service account / first admin / OAuth client to the ./config +# values and writes the generated OAuth client creds into proxy-secrets.js), +# then starts the proxy. # # What it does, in order: # 1. Update the git submodules to the latest of their tracked remote branch -# (so each run builds the newest sso-manager-node + proxy), then verify the -# build contexts are present. Skip with SKIP_SUBMODULE_UPDATE=1. -# 2. Validate .env (copy from .env.example if missing) + the REQUIRED values. -# 3. docker compose up -d sso-manager; wait for /health. -# 4. docker compose exec sso-manager node /bootstrap/bootstrap.js -# -> prints CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. -# 5. Write ./proxy.env from .env + the bootstrap output (the proxy's app_* env). -# 6. docker compose up -d proxy; wait for /health. +# (so each run builds the newest sso-manager-node + proxy). Skip with +# SKIP_SUBMODULE_UPDATE=1. +# 2. ensure_config: create ./config/sso-secrets.js + proxy-secrets.js if +# missing. On a fresh clone they're generated with random secrets and you +# must edit them + re-run. On an existing deployment with .env/proxy.env, +# the secrets are migrated (preserved) into ./config. If ./config already +# exists it is left untouched (the operator owns it). +# 3. backup_before_rebuild: snapshot ./config + LDAP (slapcat) + both Redis +# (BGSAVE + dump.rdb) to ./backups// before the rebuild. No-op on the +# very first run. Keeps the last BACKUP_KEEP (default 5). +# 4. docker compose up -d --build sso-manager; wait for /health. +# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js +# -> creates/updates the LDAP service account, first admin, OAuth client; +# writes the OAuth client creds into ./config/proxy-secrets.js; prints +# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. +# 6. docker compose up -d --build proxy; wait for /health. # 7. Print the first-admin login + the public URLs. # -# Requires: git, docker + docker compose (v1 standalone or v2 plugin). The -# compose file uses `version: '3.8'` + single-level ${VAR} interpolation so v1 -# works. +# Requires: git, docker + docker compose (v1 standalone or v2 plugin). set -euo pipefail cd "$(dirname "$0")" +CONFIG_DIR=./config +BACKUP_DIR=./backups +BACKUP_KEEP="${BACKUP_KEEP:-5}" + # ── 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; } +# Escape a value for a single-quoted JS string: \ -> \\, ' -> \', then wrap in '...'. +js_str() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\'/\\\'}" + printf "'%s'" "$s" +} + +# Random hex (openssl if available, else /dev/urandom). +rand_hex() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex "${1:-32}" + else + head -c "$((${1:-32} / 2 + 1))" /dev/urandom | od -An -tx1 | tr -d ' \n' | cut -c1-$((2 * ${1:-32})) + fi +} + # Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`). if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) @@ -45,56 +75,18 @@ else die "docker compose not found. Install Docker Compose (v2 plugin or v1 standalone)." fi -# ── 1. Update submodules to latest, verify build contexts ───────────────────── -# Pull the newest code for both submodules (sso-manager-node, proxy) so each -# run builds from current upstream, not whatever was pinned at clone time. -# `--init` also populates the submodules if the repo was cloned without -# --recursive; `--remote` checks out the tip of each submodule's tracked remote -# branch (master, per .gitmodules). Set SKIP_SUBMODULE_UPDATE=1 to lock to the -# pinned commits (offline rebuild / deliberate pin). -if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then - if ! command -v git >/dev/null 2>&1; then - die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits." - fi - info "Updating submodules to latest (sso-manager-node, proxy)..." - # Fetch + checkout each submodule's remote tip. Don't hard-fail if the fetch - # is unreachable (offline) — warn and build whatever's already checked out. - if ! git submodule update --init --remote --recursive 2>&1; then - warn "git submodule update failed (offline?) — continuing with the currently checked-out code." - fi -else - info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)." -fi +# Is a named container running? (compose-independent check.) +running() { docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$1"; } -# The compose build contexts must exist or `docker compose build` fails obscurely. -[[ -f sso-manager-node/Dockerfile.openldap ]] \ - || die "sso-manager-node/Dockerfile.openldap missing. Run: git submodule update --init --recursive" -[[ -f proxy/Dockerfile ]] \ - || die "proxy/Dockerfile missing. Run: git submodule update --init --recursive" - -# ── 2. 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 - -# Load .env the same way `docker compose` does — the value is everything after -# the FIRST '=' on the line — so values with spaces (e.g. `ORG_NAME=My Org`) -# work identically here and in compose. `source .env` would instead treat -# `ORG_NAME=My Org` as `ORG_NAME=My` + a `Org` command and abort under errexit. -# Outer wrapping quotes (single or double) around a value are stripped. -# Lines that are blank, start with '#', have no '=', or whose key isn't a -# valid identifier are skipped. No shell expansion/eval is performed on values. -load_env() { - local line key val qc +# Parse a KEY=VALUE file into the environment the way `docker compose` does: +# the value is everything after the FIRST '=' (so `ORG_NAME=My Org` works), +# outer wrapping quotes are stripped, blank/#/no-=/invalid-identifier lines +# are skipped. No shell expansion/eval is performed on values. +parse_kv_file() { + local file="$1" line key val qc + [[ -f "$file" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do - line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + line="${line#"${line%%[![:space:]]*}"}" [[ -z "$line" || "${line:0:1}" == '#' ]] && continue [[ "$line" == *=* ]] || continue key="${line%%=*}" @@ -107,68 +99,295 @@ load_env() { fi fi export "$key=$val" - done < .env + done < "$file" } -load_env -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 ' ')" +# ── 1. Update submodules to latest, verify build contexts ───────────────────── +if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then + if ! command -v git >/dev/null 2>&1; then + die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits." 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 + info "Updating submodules to latest (sso-manager-node, proxy)..." + if ! git submodule update --init --remote --recursive 2>&1; then + warn "git submodule update failed (offline?) — continuing with the currently checked-out code." fi - info "Generated + persisted JWT_SECRET into .env (save it — it signs all tokens)." +else + info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)." 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 ' ')" +[[ -f sso-manager-node/Dockerfile.openldap ]] \ + || die "sso-manager-node/Dockerfile.openldap missing. Run: git submodule update --init --recursive" +[[ -f proxy/Dockerfile ]] \ + || die "proxy/Dockerfile missing. Run: git submodule update --init --recursive" + +# ── 2. ensure_config ────────────────────────────────────────────────────────── +# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar). +domain_from_dn() { + echo "$1" | sed 's/^dc=//; s/,dc=/./g' +} + +# Write ./config/sso-secrets.js from the CFG_* shell vars. +write_sso_secrets() { + local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN" + [[ -n "$domain" ]] || domain="$(domain_from_dn "$dn")" + cat > "$CONFIG_DIR/sso-secrets.js" <}"), + }, + oauth: { + issuer: $(js_str "https://${CFG_SSO_HOST}"), + jwtSecret: $(js_str "$CFG_JWT_SECRET"), + token_lifetime: { access_token: 3600, refresh_token: 2592000 }, + }, + + // ── Orchestrator-only (ignored by the app) ─────────────────────────────── + stack: { + ldapBaseDn: $(js_str "$dn"), + ldapDomain: $(js_str "$domain"), + ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"), + ssoHost: $(js_str "$CFG_SSO_HOST"), + proxyHost: $(js_str "$CFG_PROXY_HOST"), + }, + bootstrap: { + adminUid: $(js_str "$CFG_ADMIN_UID"), + adminPass: $(js_str "$CFG_ADMIN_PASS"), + adminEmail: $(js_str "$CFG_ADMIN_EMAIL"), + }, + serviceAccountPass: $(js_str "$CFG_SVC_PASS"), +}; +SSOEOF +} + +# Write ./config/proxy-secrets.js from the CFG_* shell vars. clientId/clientSecret +# are placeholders; the bootstrap writes the generated values back into this file. +write_proxy_secrets() { + local dn="$CFG_BASE_DN" + cat > "$CONFIG_DIR/proxy-secrets.js" <> .env + + # Defaults for a fresh generation. Overridden below by .env/proxy.env if the + # operator is migrating from the old .env-based setup. + CFG_BASE_DN="${CFG_BASE_DN:-dc=example,dc=com}" + CFG_DOMAIN="${CFG_DOMAIN:-}" + CFG_ORG="${CFG_ORG:-SSO Manager}" + CFG_SSO_HOST="${CFG_SSO_HOST:-sso.example.com}" + CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.example.com}" + CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}" + CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}" + CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}" + CFG_CLIENT_ID="${CFG_CLIENT_ID:-}" + CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}" + # Random secrets (generated fresh unless migrated from .env below). + CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-$(rand_hex 16)}" + CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}" + CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}" + CFG_SVC_PASS="${CFG_SVC_PASS:-$(rand_hex 16)}" + + # ── One-time migration from .env / proxy.env (existing deployments) ── + # Preserve the operator's existing secrets so the running deployment keeps + # its LDAP directory, JWT, and OAuth client. After migration .env/proxy.env + # are dead weight — setup.sh prints a reminder to delete them. + local migrated=0 + if [[ -f .env ]]; then + info "Migrating secrets from .env into $CONFIG_DIR/ ..." + parse_kv_file .env + CFG_BASE_DN="${LDAP_BASE_DN:-$CFG_BASE_DN}" + CFG_DOMAIN="${LDAP_DOMAIN:-$CFG_DOMAIN}" + CFG_ORG="${ORG_NAME:-$CFG_ORG}" + CFG_SSO_HOST="${SSO_HOST:-$CFG_SSO_HOST}" + CFG_PROXY_HOST="${PROXY_HOST:-$CFG_PROXY_HOST}" + CFG_ADMIN_UID="${BOOTSTRAP_ADMIN_UID:-$CFG_ADMIN_UID}" + CFG_ADMIN_EMAIL="${BOOTSTRAP_ADMIN_EMAIL:-$CFG_ADMIN_EMAIL}" + CFG_LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$CFG_LDAP_ADMIN_PASS}" + CFG_JWT_SECRET="${JWT_SECRET:-$CFG_JWT_SECRET}" + CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}" + CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}" + CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}" + CFG_SMTP_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}" + CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}" + CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}" + CFG_SMTP_PASS="${SMTP_PASS:-${CFG_SMTP_PASS:-}}" + CFG_SMTP_FROM="${SMTP_FROM:-${CFG_SMTP_FROM:-}}" + migrated=1 fi - info "Generated + persisted LDAP_SERVICE_PASS into .env." -fi + if [[ -f proxy.env ]]; then + info "Migrating proxy config from proxy.env into $CONFIG_DIR/ ..." + # proxy.env uses app_* keys; pull the OAuth client creds out directly. + CFG_CLIENT_ID="$(grep -m1 '^app_oidc__clientId=' proxy.env 2>/dev/null | cut -d= -f2- || true)" + CFG_CLIENT_SECRET="$(grep -m1 '^app_oidc__clientSecret=' proxy.env 2>/dev/null | cut -d= -f2- || true)" + # LDAP_BIND_PASSWORD in proxy.env == the service account pass. + local pbp; pbp="$(grep -m1 '^app_ldap__bindPassword=' proxy.env 2>/dev/null | cut -d= -f2- || true)" + [[ -n "$pbp" ]] && CFG_SVC_PASS="$pbp" + migrated=1 + fi + [[ -n "$CFG_ADMIN_EMAIL" ]] || CFG_ADMIN_EMAIL="admin@${CFG_PROXY_HOST}" -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}" + mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR" + write_sso_secrets + write_proxy_secrets + chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js" -# ── 3. Start SSO Manager, wait for health ───────────────────────────────────── -# Compose v2 validates env_file paths for ALL services in the project at load -# time — including the proxy's ./proxy.env — even when only sso-manager is -# being started. proxy.env isn't written until step 4 (from the bootstrap -# output), so create an empty stub here on first run so compose v2 doesn't bail -# with "env file ./proxy.env not found". The stub carries no vars (a proxy not -# yet started reads nothing from it); step 4 overwrites it with the real config. -# (compose v1 only loads env_file for services being started, so this is a -# no-op there — touch is harmless on an existing, populated proxy.env.) -touch ./proxy.env + if [[ "$migrated" == "1" ]]; then + info "Migrated secrets into $CONFIG_DIR/ (existing LDAP dir / JWT / OAuth client preserved)." + info "You may now delete .env and proxy.env — they are no longer used." + else + # Fresh generation with placeholder hostnames — the operator must edit. + info "Generated $CONFIG_DIR/sso-secrets.js + proxy-secrets.js with random secrets." + warn "EDIT $CONFIG_DIR/sso-secrets.js (set stack.ssoHost, stack.proxyHost, stack.ldapBaseDn," + warn " bootstrap.adminUid/adminPass to your values), then re-run ./setup.sh." + info "Re-run ./setup.sh after editing. (LDAP admin pass + JWT were generated for you —" + info "change them in the file if you like, or leave them.)" + exit 0 + fi +} +ensure_config + +# ── 3. backup_before_rebuild ────────────────────────────────────────────────── +# Snapshot ./config + LDAP (slapcat) + both Redis (BGSAVE + dump.rdb) before the +# rebuild. No-op on the very first run (nothing running, no config to lose yet). +backup_before_rebuild() { + local any_running=0 + running sso-manager && any_running=1 + running proxy && any_running=1 + if [[ "$any_running" == "0" && ! -d "$CONFIG_DIR" ]]; then + info "First run — nothing to back up yet." + return 0 + fi + + # A timestamp suffix. `date` is fine here (setup.sh runs on the host). + local ts; ts="$(date +%Y%m%d-%H%M%S)" + local dir="$BACKUP_DIR/$ts" + mkdir -p "$dir" && chmod 700 "$dir" + info "Snapshotting state to $dir/ before rebuild..." + + # Config (the secrets source — the most important thing to back up). + if [[ -d "$CONFIG_DIR" ]]; then + cp -a "$CONFIG_DIR" "$dir/config" 2>/dev/null \ + || warn " could not copy $CONFIG_DIR/" + fi + + # LDAP — slapcat the live directory while slapd is running. + if running sso-manager; then + local basedn + basedn="$("${COMPOSE[@]}" exec -T sso-manager node -e \ + 'console.log((require("/config/sso-secrets.js").stack||{}).ldapBaseDn||"")' 2>/dev/null || true)" + if [[ -n "$basedn" ]]; then + if "${COMPOSE[@]}" exec -T sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "$basedn" > "$dir/ldap.ldif" 2>/dev/null; then + info " LDAP -> ldap.ldif ($basedn)" + else + warn " slapcat failed (LDAP not ready?) — LDAP not snapshotted" + fi + else + warn " could not read ldapBaseDn from sso-secrets.js — LDAP not snapshotted" + fi + fi + + # Redis — hot snapshot each running service: BGSAVE, wait for LASTSAVE to + # advance (<=30s), then copy the RDB out. + local svc pid + for svc in sso-manager proxy; do + running "$svc" || continue + if ! docker exec "$svc" redis-cli BGSAVE >/dev/null 2>&1; then + warn " $svc: redis-cli BGSAVE failed — not snapshotted" + continue + fi + local before ok=0 + before="$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)" + for i in $(seq 1 30); do + if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9')" -gt "$before" ]]; then + ok=1; break + fi + sleep 1 + done + if [[ "$ok" == "1" ]] && "${COMPOSE[@]}" cp "$svc:/data/dump.rdb" "$dir/$svc.rdb" >/dev/null 2>&1; then + info " Redis ($svc) -> $svc.rdb" + else + warn " $svc: BGSAVE did not finish in 30s — Redis not snapshotted" + fi + done + + # Retention: keep the newest BACKUP_KEEP (min 1). + local keep="$BACKUP_KEEP"; (( keep < 1 )) && keep=1 + local removed=0 + while read -r old; do + [[ -n "$old" ]] || continue + rm -rf "$BACKUP_DIR/$old" + removed=$((removed + 1)) + done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | sort -r | tail -n +$((keep + 1))) + [[ "$removed" -gt 0 ]] && info " pruned $removed old backup(s) (keeping $keep)." +} +backup_before_rebuild + +# ── 4. 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 @@ -177,7 +396,6 @@ 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 @@ -185,95 +403,51 @@ for i in $(seq 1 60); do sleep 2 done -# ── 4. 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 holds a secret, keep it (the proxy can still read it); if -# not, rotate so a wiped-and-restored proxy gets a usable secret. We check for -# an actual app_oidc__clientSecret line rather than mere file existence because -# step 2 above may have created an empty stub (so compose v2's project-wide -# env_file validation passes) — a bare stub must NOT suppress first-run client -# creation or wiped-proxy secret rotation. -PROXY_ENV_EXISTS=0 -if [[ -f ./proxy.env ]] && grep -q '^app_oidc__clientSecret=' ./proxy.env; then - PROXY_ENV_EXISTS=1 -fi +# Read the summary values (hosts, admin, base DN) back from ./config via the +# running container's node — works whether ./config was generated or pre-existing. +read_config_kv() { + "${COMPOSE[@]}" exec -T sso-manager node -e ' + const c = require("/config/sso-secrets.js"); + const o = { + SSO_HOST: (c.stack && c.stack.ssoHost) || "", + PROXY_HOST: (c.stack && c.stack.proxyHost) || "", + LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "", + ORG_NAME: c.name || "", + ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "", + ADMIN_PASS: (c.bootstrap && c.bootstrap.adminPass) || "", + }; + for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k])); + ' 2>/dev/null +} +CFG_OUT="$(read_config_kv || true)" +cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; } +SSO_HOST="$(cfgval SSO_HOST)" +PROXY_HOST="$(cfgval PROXY_HOST)" +ADMIN_UID="$(cfgval ADMIN_UID)" +ADMIN_PASS="$(cfgval ADMIN_PASS)" +info "Stack config:" +info " SSO host: https://${SSO_HOST}" +info " Proxy host: https://${PROXY_HOST}" +info " Admin uid: ${ADMIN_UID}" + +# ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ── +# The bootstrap reads its inputs from /config/*.js (not env) and writes the +# generated OAuth client creds back into /config/proxy-secrets.js. No -e flags. 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) \ +BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T 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}" - -# ── 5. 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." + info "Stack was already configured — OAuth client creds in proxy-secrets.js are current." else - info "OAuth client registered + proxy.env written." + info "OAuth client registered + creds written into $CONFIG_DIR/proxy-secrets.js." fi # ── 6. Start the proxy, wait for health ─────────────────────────────────────── @@ -299,12 +473,15 @@ 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 " user: ${ADMIN_UID}" +echo " pass: ${ADMIN_PASS}" +echo +echo " Secrets live in ./config/ (sso-secrets.js + proxy-secrets.js). Back them" +echo " up off-host — ./setup.sh snapshots to ./backups/ before each rebuild." 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 +echo " Re-run ./setup.sh any time to converge the stack to ./config/ (idempotent)." \ No newline at end of file diff --git a/sso-manager-node b/sso-manager-node index d5e951f..6920a9f 160000 --- a/sso-manager-node +++ b/sso-manager-node @@ -1 +1 @@ -Subproject commit d5e951fa9a9b63df1d55b336df92774fae30a9d3 +Subproject commit 6920a9f9f0c513142c3eef536b3985656a04f2cd