Persist Redis + config in bind-mounted ./config/ (no .env); add backup/restore (#8)
Part A — lossless upgrades: - Persist both bundled Redis stores via AOF+RDB on named volumes (sso-data, proxy-data) so OAuth clients, Host records, perms, DNS creds, and auto-ssl Let's Encrypt certs survive rebuilds. - setup.sh: backup_before_rebuild() snapshots ./config/ + LDAP (slapcat) + both Redis (BGSAVE + compose cp) to ./backups/<ts>/ before each rebuild, keeps last BACKUP_KEEP (default 5). First run is a no-op. - Restore runbook (README + docs): full / Redis-only / LDAP-only, with the AOF-vs-RDB note (delete the AOF before restoring an RDB). Part B — eliminate .env / proxy.env: - All config + secrets live in bind-mounted ./config/ (gitignored), read by each app's @simpleworkjs/conf from a symlinked secrets.js. Compose passes only NODE_ENV + NODE_PORT (no app_* env, which would override secrets.js). - ./config/sso-secrets.js: app secrets + orchestrator-only stack/bootstrap/ serviceAccountPass keys (app ignores the ones it doesn't use). - ./config/proxy-secrets.js: oidc (clientId/clientSecret filled in by the bootstrap), ldap (bind creds), auth (admin groups/users). - setup.sh ensure_config(): generates ./config/ with random secrets on first run (then exits for editing); one-time migration from .env/proxy.env preserving existing secrets (LDAP admin pass, JWT, OAuth client, service pass) so a running deployment keeps its directory + tokens + OAuth client. - bootstrap/bootstrap.js: reads /config/*.js (not process.env), registers the proxy as an OIDC client, and writes the SSO-generated client id+secret back into ./config/proxy-secrets.js (sso mounts ./config RW, proxy RO). - config.example/ holds committed annotated templates for manual reference. - .gitignore: add config/, backups/, *.rdb, *.ldif. Bump both gitlinks to the merged submodule tips: - sso-manager-node -> 6920a9f (PR #34) - proxy -> 8e78604 (PR #118) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -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
|
||||
@@ -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/<timestamp>/` 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,<base>`),
|
||||
- creates your first admin user and adds them to the `app_sso_admin` +
|
||||
`app_sso_oauth_admin` groups,
|
||||
- registers the proxy as an OIDC client in the SSO, and
|
||||
- prints the client id + secret.
|
||||
3. Writes `./proxy.env` (the proxy's config — OIDC endpoints, LDAP bind,
|
||||
client creds) from your `.env` + the bootstrap output.
|
||||
- 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 <noreply@example.com>"`. 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,<base>" -W -b "<base>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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/<timestamp>/` **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/<ts>/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/<ts>/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/<ts>/${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 "<base>"`.
|
||||
|
||||
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=<path>` 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=<path>` 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
|
||||
|
||||
Vendored
+107
-46
@@ -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=<uid>,ou=people,<base>). Distinct from
|
||||
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) 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,<base>) 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');
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
+45
-26
@@ -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://<SSO_HOST>.
|
||||
# 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_HOST>.
|
||||
- "${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:
|
||||
+52
-24
@@ -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,<base>` (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 → <env>.js → conf/secrets.js → app_*
|
||||
env`, where **env beats `secrets.js`**. So compose passes **no `app_*` env vars**
|
||||
(only `NODE_ENV`, `NODE_PORT`) — that makes `secrets.js` authoritative. The SSO
|
||||
entrypoint reads the few values it needs at startup (LDAP base DN, admin
|
||||
password, JWT secret, cert CN) from `secrets.js` via an in-container `node` call.
|
||||
|
||||
### Why not `require` the SSO's internal models?
|
||||
|
||||
A `docker compose exec` process reads `conf/base.js` defaults (the docker-exec
|
||||
env doesn't carry the entrypoint's exported `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/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
|
||||
default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`)
|
||||
and survives recreation; `down -v` wipes them. Redis is persisted with AOF +
|
||||
RDB on those volumes. For the full manual-backup + restore runbook (full /
|
||||
Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and
|
||||
restore* section of the [README](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 "<base>" > 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)
|
||||
+21
-17
@@ -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://<host>:636` — legacy apps can bind directly (admin or
|
||||
the read-only `cn=ldapclient` service account the bootstrap creates).
|
||||
|
||||
## The `.env` values you must set
|
||||
## 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.
|
||||
|
||||
+45
-38
@@ -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/<timestamp>/` 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://<SSO_HOST>` 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://<PROXY_HOST>` (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://<host>: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/<ts>/`
|
||||
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 "<base>" > 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.
|
||||
|
||||
+20
-29
@@ -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://<host>: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 <<EOF
|
||||
app_oidc__issuer=https://sso.example.com
|
||||
app_oidc__authorizationEndpoint=https://sso.example.com/oauth/authorize
|
||||
app_oidc__endSessionEndpoint=https://sso.example.com/oauth/logout
|
||||
app_oidc__tokenEndpoint=https://sso.example.com/oauth/token
|
||||
app_oidc__userinfoEndpoint=https://sso.example.com/oauth/userinfo
|
||||
app_oidc__clientId=...
|
||||
app_oidc__clientSecret=...
|
||||
app_oidc__redirectUri=https://proxy.example.com/api/auth/oidc/callback
|
||||
app_ldap__url=ldaps://sso.example.com:636
|
||||
app_ldap__bindDN=cn=ldapclient,ou=people,dc=example,dc=com
|
||||
app_ldap__bindPassword=...
|
||||
app_ldap__searchBase=ou=people,dc=example,dc=com
|
||||
app_ldap__userFilter=(objectClass=posixAccount)
|
||||
app_ldap__tlsOptions__rejectUnauthorized=false
|
||||
EOF
|
||||
mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The entrypoint symlinks `config/proxy-secrets.js` to `nodejs/conf/secrets.js` so
|
||||
`@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
|
||||
`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and
|
||||
`auth` (admin groups/users). Pass **no `app_*` env** — env beats `secrets.js`,
|
||||
so `app_*` would silently override your file.
|
||||
|
||||
- Proxy (public, auto-SSL): `https://<host>/`
|
||||
- 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
|
||||
|
||||
+1
-1
Submodule proxy updated: 3f178b038c...8e78604a37
@@ -3,39 +3,69 @@
|
||||
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
|
||||
#
|
||||
# git clone --recursive <theta-env> && cd theta-env
|
||||
# cp .env.example .env # edit the REQUIRED values
|
||||
# ./setup.sh
|
||||
# ./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/<ts>/ 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" <<SSOEOF
|
||||
'use strict';
|
||||
// Generated by setup.sh. Edit freely; re-run ./setup.sh to apply.
|
||||
// The SSO app reads this via @simpleworkjs/conf (symlinked to conf/secrets.js).
|
||||
// The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by
|
||||
// the orchestrator). Back this file up off-host — it holds all SSO secrets.
|
||||
|
||||
module.exports = {
|
||||
name: $(js_str "$CFG_ORG"),
|
||||
ldap: {
|
||||
url: 'ldap://localhost:389',
|
||||
bindDN: $(js_str "cn=admin,${dn}"),
|
||||
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
|
||||
userBase: $(js_str "ou=people,${dn}"),
|
||||
groupBase: $(js_str "ou=groups,${dn}"),
|
||||
},
|
||||
smtp: {
|
||||
host: $(js_str "${CFG_SMTP_HOST:-}"),
|
||||
port: ${CFG_SMTP_PORT:-587},
|
||||
secure: false,
|
||||
user: $(js_str "${CFG_SMTP_USER:-}"),
|
||||
pass: $(js_str "${CFG_SMTP_PASS:-}"),
|
||||
from: $(js_str "${CFG_SMTP_FROM:-${CFG_ORG} <noreply@${domain}>}"),
|
||||
},
|
||||
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" <<PROXYEOF
|
||||
'use strict';
|
||||
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (symlinked
|
||||
// to conf/secrets.js). clientId/clientSecret are filled in by the bootstrap
|
||||
// (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal
|
||||
// serviceAccountPass in sso-secrets.js (the proxy binds as that account).
|
||||
|
||||
module.exports = {
|
||||
oidc: {
|
||||
enabled: true,
|
||||
issuer: $(js_str "https://${CFG_SSO_HOST}"),
|
||||
authorizationEndpoint: $(js_str "https://${CFG_SSO_HOST}/oauth/authorize"),
|
||||
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
|
||||
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
|
||||
endSessionEndpoint: $(js_str "https://${CFG_SSO_HOST}/oauth/logout"),
|
||||
clientId: $(js_str "$CFG_CLIENT_ID"),
|
||||
clientSecret: $(js_str "$CFG_CLIENT_SECRET"),
|
||||
redirectUri: $(js_str "https://${CFG_PROXY_HOST}/api/auth/oidc/callback"),
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
groupsClaim: 'groups',
|
||||
usernameClaim: 'preferred_username',
|
||||
},
|
||||
ldap: {
|
||||
url: 'ldaps://sso-manager:636',
|
||||
bindDN: $(js_str "cn=ldapclient,ou=people,${dn}"),
|
||||
bindPassword: $(js_str "$CFG_SVC_PASS"),
|
||||
searchBase: $(js_str "ou=people,${dn}"),
|
||||
userFilter: '(objectClass=posixAccount)',
|
||||
userNameAttribute: 'uid',
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
},
|
||||
auth: {
|
||||
adminGroups: ['app_sso_admin'],
|
||||
adminUsers: ['proxyadmin2'],
|
||||
groupRoleMap: {},
|
||||
},
|
||||
stack: {
|
||||
ssoHost: $(js_str "$CFG_SSO_HOST"),
|
||||
proxyHost: $(js_str "$CFG_PROXY_HOST"),
|
||||
},
|
||||
};
|
||||
PROXYEOF
|
||||
}
|
||||
|
||||
ensure_config() {
|
||||
if [[ -f "$CONFIG_DIR/sso-secrets.js" ]]; then
|
||||
info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)."
|
||||
return 0
|
||||
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
|
||||
|
||||
# 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)."
|
||||
echo " Re-run ./setup.sh any time to converge the stack to ./config/ (idempotent)."
|
||||
+1
-1
Submodule sso-manager-node updated: d5e951fa9a...6920a9f9f0
Reference in New Issue
Block a user