diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 67e93a6..74ce147 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -45,53 +45,60 @@ need to set a few secrets. ### Setup +The bundled `docker-compose.yml` reads config from a bind-mounted +`./config/sso-secrets.js` (not from a `.env` file). Copy the example, fill in +your secrets, then build + start: + ```bash -# Minimal: set an LDAP admin password and a JWT secret, then build + start. -LDAP_ADMIN_PASS='choose-a-strong-password' \ -JWT_SECRET="$(openssl rand -hex 32)" \ +mkdir -p config && chmod 700 config +cp secrets.js.example config/sso-secrets.js +$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ... docker compose up -d --build ``` -For a customized deployment, put the overrides in a `.env` file next to -`docker-compose.yml`: +`docker-entrypoint.sh` symlinks `/config/sso-secrets.js` → `/app/conf/secrets.js` +so `@simpleworkjs/conf` reads it, and pulls the server-side LDAP vars (base DN, +admin password, org, domain, cert CN, JWT secret) out of the same file. No +`app_*` env is passed — `app_*` env would override `secrets.js` (env beats the +file in `@simpleworkjs/conf`), so the file is kept authoritative. -```env -LDAP_BASE_DN=dc=yourdomain,dc=com -LDAP_DOMAIN=yourdomain.com -LDAP_ADMIN_PASS=your-admin-password -ORG_NAME=Your Org -JWT_SECRET=your-jwt-secret -OAUTH_ISSUER=https://sso.yourdomain.com # browser-facing URL the proxy serves -LDAP_CERT_CN=sso.yourdomain.com # hostname LDAPS clients verify against -SMTP_HOST=smtp.yourdomain.com -SMTP_PORT=587 -SMTP_USER=noreply@yourdomain.com -SMTP_PASS=your-smtp-password -SMTP_FROM=Your Org -PORT=3001 -LDAPS_PORT=636 -# LDAP_PORT=389 # uncomment the 389 host mapping in compose if you need plain LAN binds -``` +> Running the unified `theta-env` stack? Its `setup.sh` generates +> `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`) for you with random +> secrets and snapshots state before rebuilds — see the theta-env README. -Then `docker compose up -d --build`. +**Quick test (defaults):** with no `./config/sso-secrets.js` the entrypoint +falls back to env-mode with safe defaults (`dc=example,dc=com`, admin password +`admin`, an auto-generated JWT secret) — fine for kicking the tires, not for +production. + +**Advanced — env vars instead of the file:** the entrypoint also supports +config via `LDAP_*` / `app_*` env vars (env-mode, used when +`/config/sso-secrets.js` is absent). Since the bundled compose no longer passes +those env vars, you'd add them to its `environment:` block yourself, e.g. +`LDAP_ADMIN_PASS`, `JWT_SECRET`, `app_oauth__issuer`. This is mainly for +bare-metal / advanced standalone use; most deployments should use the file. ### What the entrypoint does `docker-entrypoint.sh` (run as the container entrypoint): -1. Generates a self-signed TLS cert (unless one is already present at +1. If `/config/sso-secrets.js` is mounted, symlinks it to `/app/conf/secrets.js` + and reads the server-side LDAP vars from it (secrets.js mode). Otherwise it + derives them from `LDAP_*` env vars with safe defaults (env mode). +2. Generates a self-signed TLS cert (unless one is already present at `LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb` database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS, indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf` listening on `ldap:///` (389) and `ldaps:///` (636). -2. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default +3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default `pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`) — idempotently, so container restarts are safe. -3. Starts a bundled Redis (the app uses `model-redis` for models/sessions), unless +4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and + stores OAuth clients there), AOF+RDB persisted to `/data`, unless `app_redis__host` is set (then it's expected to be external). -4. Exports `app_*` env vars so the app binds to the local slapd (any `app_*` you - set in the compose environment wins over the entrypoint's defaults). -5. `exec`s `node bin/www`. +5. In env mode, exports `app_*` env vars so the app binds to the local slapd. In + secrets.js mode it exports none (the app reads the file directly). +6. `exec`s `node bin/www`. ### Access @@ -191,20 +198,84 @@ proxy and a natural fit — it's both an **OIDC client** of the SSO Manager *and create a dedicated LDAP service account under `ou=people` (e.g. `cn=ldapclient,ou=people,…`) via the SSO Manager UI — don't reuse the admin DN. -### Backups (small-business / ~100 users) +### Backups and restore -- **LDAP data** lives on the `ldap-data` volume (`/var/lib/ldap` in the container). - Back up the directory with an `ldapsearch`/`slapcat` export on a schedule: - ```bash - docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ - -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif - ``` - (Restorable with `ldapadd`/`ldapmodify` against a fresh instance.) -- **Redis** is in-memory and not persisted by default (session/cache only — safe - to lose). If you want session durability, mount a Redis AOF/RDB volume and - enable persistence in `docker-entrypoint.sh`. -- **JWT_SECRET** and **LDAP_ADMIN_PASS** are operational secrets — store them - outside the container (your `.env`, a password manager, etc.). +**What lives where** + +| State | Location | Persisted? | +|-------|----------|------------| +| LDAP directory (users, groups, policies) | `ldap-data` volume (`/var/lib/ldap`) | yes (volume) | +| LDAP TLS cert | `ldap-certs` volume (`/etc/openldap/certs`) | yes (volume) | +| Redis (OAuth clients, tokens, sessions) | `sso-data` volume (`/data`) | yes (AOF + RDB) | +| Secrets (LDAP admin pass, JWT secret, SMTP) | `./config/sso-secrets.js` (bind mount) | your responsibility — back up off-host | + +**Automatic snapshots** — when run as part of the unified `theta-env` stack, +`setup.sh` snapshots LDAP + Redis + `./config/` to `./backups//` +before every rebuild and keeps the last `BACKUP_KEEP` (default 5). Standalone +deployments don't get this; use the manual steps below. + +**Manual backup** + +```bash +# LDAP — full directory export (works while slapd is running) +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif + +# Redis — hot snapshot: trigger a save, then copy the RDB out +docker compose exec sso-manager redis-cli BGSAVE +docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb + +# Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.) +cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F) +``` +Store the `.ldif`, `.rdb`, and config copy **off the host** — they contain +secrets and the whole user directory. + +**Restore — full (disaster recovery)** + +The SSO image uses a static `slapd.conf` (slapd starts with `-f`, not `-F` +cn=config), so LDAP restore uses `slapadd -f /etc/openldap/slapd.conf`: + +```bash +# 1. Secrets +cp -a config-backup- ./config && chmod 700 ./config +./setup.sh # fresh empty volumes (or: docker compose up -d) +docker compose stop sso-manager + +# 2. LDAP — wipe the mdb files, then load the LDIF into the stopped directory +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' \ + < ldap-backup-.ldif +docker compose start sso-manager + +# 3. Redis — see the AOF note below +docker compose stop sso-manager +docker compose run --rm --no-deps --entrypoint sh sso-manager -c \ + 'rm -f /data/appendonly.aof /data/appendonly.aof.*' # REQUIRED — see note +docker compose cp sso-redis-.rdb sso-manager:/data/dump.rdb +docker compose start sso-manager +``` + +**Restore — Redis only** = step 3 above. **Restore — LDAP only** = step 2 above. + +> **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` and +> `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. + +**Upgrades** + +```bash +./setup.sh # backs up, then rebuilds — volumes keep LDAP + Redis state +# (standalone) docker compose pull && docker compose up -d +``` +LDAP data and Redis state survive the rebuild because they live on named +volumes, not in the image. Verify health (`docker compose ps`, log in, check an +OAuth client). Note: re-running bootstrap resets the bootstrap-admin and +service-account passwords to the values in `./config/sso-secrets.js`; non-theta +OAuth clients live in SSO Redis and are preserved by the volume. --- diff --git a/docker-compose.yml b/docker-compose.yml index 4620b10..c832b09 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,20 @@ # Docker Compose for the SSO Manager all-in-one image (app + OpenLDAP in one container). # # The image (Dockerfile.openldap) bundles a slapd that the app talks to over -# localhost. Configuration is supplied to the app via `app_*` environment -# variables — the highest-precedence config layer in @simpleworkjs/conf. Any -# `app_*` var not set here falls back to a sensible default baked into -# docker-entrypoint.sh (e.g. app_ldap__url -> ldap://localhost:389). +# localhost. The app reads its configuration from conf/base.js + conf/secrets.js +# (deep-merged by @simpleworkjs/conf). The operator-edited secrets live in a +# bind-mounted ./config/sso-secrets.js, which docker-entrypoint.sh symlinks into +# /app/conf/secrets.js on startup. No app_* env vars are passed here: any app_* +# env would override secrets.js (env beats the file in @simpleworkjs/conf), so +# the file must be the only source. # -# Copy this file to .env (or export the vars) to set the secrets below, or -# set them inline on the command line: -# LDAP_ADMIN_PASS=... JWT_SECRET=... docker compose up -d +# Compose only interpolates the port defaults below — there is no .env file. +# Override a port on the command line if needed: +# PORT=3002 LDAPS_PORT=1636 docker compose up -d # -# Requires @simpleworkjs/conf >= 1.1.0 in the image (env overrides). Refresh +# Requires @simpleworkjs/conf >= 1.1.0 in the image. Refresh # nodejs/package-lock.json with `npm install @simpleworkjs/conf@^1.1.0` before -# building, so the image actually contains the env-override feature. +# building. services: sso-manager: @@ -34,45 +36,16 @@ services: # plain binds from the LAN (not recommended): # - "${LDAP_PORT:-389}:389" environment: - # ── LDAP server-side (configures the bundled slapd) ── - - LDAP_BASE_DN=${LDAP_BASE_DN:-dc=example,dc=com} - - LDAP_DOMAIN=${LDAP_DOMAIN:-example.com} - - LDAP_ADMIN_PASS=${LDAP_ADMIN_PASS:-admin} # slapd root + app bind password - - ORG_NAME=${ORG_NAME:-SSO Manager} - # CN on the LDAP TLS cert. Clients verify against this hostname. Leave - # empty to default to LDAP_DOMAIN; set to the public hostname clients - # connect over (computed in docker-entrypoint.sh, not here, to avoid - # nested-variable interpolation limitations in compose v1). - - LDAP_CERT_CN=${LDAP_CERT_CN:-} - - # ── App config overrides (app_* -> @simpleworkjs/conf) ── - # Defaults come from docker-entrypoint.sh; set these to override. - # - app_oauth__issuer=https://sso.example.com - # - app_ldap__url=ldap://localhost:389 # default; points at bundled slapd - # - app_ldap__bindDN=cn=admin,${LDAP_BASE_DN} - # - app_ldap__bindPassword=${LDAP_ADMIN_PASS} - # - app_ldap__userBase=ou=people,${LDAP_BASE_DN} - # - app_ldap__groupBase=ou=groups,${LDAP_BASE_DN} - - app_oauth__jwtSecret=${JWT_SECRET} # falls back to an auto-generated secret - # OIDC issuer advertised in /.well-known/openid-configuration. This is the - # browser-facing URL the front proxy serves the SSO at — the theta42/proxy - # (an OIDC client) and other apps use it for discovery. Server-to-server - # token/userinfo calls from the proxy can go to http://sso-manager:3001 - # over the docker network; only the issuer/redirect URLs must be public. - # Leave empty to default to https://sso. (set in the entrypoint). - - app_oauth__issuer=${OAUTH_ISSUER:-} - - app_name=${ORG_NAME:-SSO Manager} - - # ── Optional SMTP (outbound email) ── - - app_smtp__host=${SMTP_HOST:-localhost} - - app_smtp__port=${SMTP_PORT:-587} - - app_smtp__user=${SMTP_USER:-} - - app_smtp__pass=${SMTP_PASS:-} - - app_smtp__from=${SMTP_FROM:-SSO Manager } - + # 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 secrets (sso-secrets.js). The entrypoint symlinks + # /config/sso-secrets.js -> /app/conf/secrets.js so @simpleworkjs/conf reads + # it. See secrets.js.example / config.example/ for the shape. + - ./config:/config:ro # 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 @@ -81,6 +54,10 @@ services: # - ./certs:/etc/openldap/certs # (must contain ldap.crt + ldap.key; the entrypoint leaves them untouched). - ldap-certs:/etc/openldap/certs + # Persist Redis (AOF + RDB) so OAuth clients, tokens, and other + # Redis-backed state survive container recreation. Restoring Redis also + # restores lua-resty-auto-ssl cert state if this image fronts a proxy. + - sso-data:/data healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] interval: 30s @@ -90,4 +67,5 @@ services: volumes: ldap-data: - ldap-certs: \ No newline at end of file + ldap-certs: + sso-data: \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c3d002c..71ba248 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -30,6 +30,48 @@ APP_LDAP_URL="${app_ldap__url:-ldap://localhost:389}" info() { echo "[INFO] $*"; } error() { echo "[ERROR] $*" >&2; } +# ── Optional: load operational config from a mounted secrets.js ────────────── +# The unified theta-env stack mounts ./config/sso-secrets.js at /config and +# treats it as the authoritative source for the SSO's config (LDAP base, admin +# password, org name, JWT secret, ...). When present, symlink it into +# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the +# env-derived operational vars below with the file's values. When absent +# (standalone / env-var deployments) the env vars set above stay in effect and +# the app_* exports further down are emitted as before. +SECRETS_JS_MODE=0 +if [[ -f /config/sso-secrets.js ]]; then + ln -sf /config/sso-secrets.js /app/conf/secrets.js + SECRETS_JS_MODE=1 + # Pull the entrypoint's operational vars out of secrets.js in one node call. + # Node emits `KEYbase64(value)` lines; we decode each with base64 -d and + # assign via printf -v. base64 carries quotes / special chars safely with no + # eval and no shell-quoting gymnastics. No app_* env is exported in this mode + # — @simpleworkjs/conf reads the file directly, and any app_* env would + # override it (precedence: base.js < .js < secrets.js < app_* env). + _node_out="$(node -e ' + const c = require("/config/sso-secrets.js"); + const b = s => Buffer.from(String(s == null ? "" : s)).toString("base64"); + const o = { + LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "", + LDAP_ADMIN_PASS: (c.ldap && c.ldap.bindPassword) || "", + ORG_NAME: c.name || "", + LDAP_DOMAIN: (c.stack && c.stack.ldapDomain) || "", + LDAP_CERT_CN: (c.stack && c.stack.ldapCertCn) || "", + JWT_SECRET: (c.oauth && c.oauth.jwtSecret) || "", + }; + for (const k in o) console.log(k + "\t" + b(o[k])); + ')" || { error "Failed to parse /config/sso-secrets.js (see stderr above)"; exit 1; } + [[ -n "$_node_out" ]] || { error "/config/sso-secrets.js produced no config"; exit 1; } + while IFS=$'\t' read -r _k _v; do + [[ -n "$_k" ]] || continue + printf -v "$_k" '%s' "$(printf '%s' "$_v" | base64 -d)" + done <<< "$_node_out" + LDAP_BIND_DN="cn=admin,${LDAP_BASE_DN}" + [[ -n "$LDAP_ADMIN_PASS" ]] || { error "/config/sso-secrets.js: ldap.bindPassword is empty"; exit 1; } + [[ -n "$JWT_SECRET" ]] || { error "/config/sso-secrets.js: oauth.jwtSecret is empty"; exit 1; } + info "Loaded config from /config/sso-secrets.js (secrets.js authoritative)" +fi + # ── Locate the OpenLDAP module directory ──────────────────────────────────── # slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The # path varies by distro; auto-detect rather than hardcode. @@ -248,13 +290,21 @@ else fi # ── Start Redis ────────────────────────────────────────────────────────────── -# The app stores models/sessions in Redis (model-redis). The all-in-one image -# bundles a Redis server for a self-contained single-node deployment. Point the -# app at an external Redis instead by setting app_redis__host before starting. -# In-memory, no persistence: cache/session data is rebuilt on restart. +# The app stores models/sessions in Redis (model-redis), and the SSO's +# non-bootstrap OAuth clients also live there. The all-in-one image bundles a +# Redis server for a self-contained single-node deployment. Persist it to /data +# (AOF + RDB) so OAuth clients, tokens, and other Redis-backed state survive +# container recreation. Point the app at an external Redis instead by setting +# app_redis__host before starting (then no bundled Redis runs here). redis runs +# as root in this image, so a root-owned /data is writable. if [[ -z "${app_redis__host:-}" ]]; then - info "Starting Redis..." - redis-server --save "" --appendonly no --daemonize no & + REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}" + mkdir -p "$REDIS_DATA_DIR" + chmod 700 "$REDIS_DATA_DIR" + info "Starting Redis (AOF persisted to $REDIS_DATA_DIR)..." + redis-server --daemonize no --dir "$REDIS_DATA_DIR" --appendonly yes \ + --appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000 \ + --dbfilename dump.rdb & REDIS_PID=$! for i in $(seq 1 15); do if redis-cli ping >/dev/null 2>&1; then @@ -271,8 +321,9 @@ if [[ -z "${app_redis__host:-}" ]]; then # is needed when running the bundled Redis. fi -# ── Generate a JWT secret if none was provided ────────────────────────────── -if [[ -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then +# ── Generate a JWT secret if none was provided (env mode only) ────────────── +# In secrets.js mode the JWT secret comes from the file and was validated above. +if [[ "${SECRETS_JS_MODE:-0}" != 1 && -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then if command -v openssl >/dev/null 2>&1; then JWT_SECRET=$(openssl rand -hex 32) else @@ -281,30 +332,34 @@ if [[ -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then info "Generated JWT secret (set JWT_SECRET or app_oauth__jwtSecret to persist)" fi -# ── Export app_* config overrides for the SSO Manager process ──────────────── -# These are the highest-precedence config layer in @simpleworkjs/conf. Any -# value already set in the environment is preserved (${VAR:-default}). -export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}" -export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}" -export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}" -export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}" -export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}" -export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}" -# OIDC issuer advertised in /.well-known/openid-configuration. Default to the -# public https URL on the SSO subdomain of the LDAP domain; override with -# OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because -# compose v1 doesn't interpolate nested ${VAR:-...} defaults. -export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}" -export app_name="${app_name:-$ORG_NAME}" +# ── Export app_* config overrides for the SSO Manager process (env mode) ───── +# In secrets.js mode the app reads /app/conf/secrets.js directly, so we export +# NO app_* vars — they would override the file (@simpleworkjs/conf precedence: +# base.js < .js < secrets.js < app_* env). In env mode these remain the +# highest-precedence layer, derived from the LDAP_* / ORG_NAME / SMTP_* env. +if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then + export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}" + export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}" + export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}" + export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}" + export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}" + export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}" + # OIDC issuer advertised in /.well-known/openid-configuration. Default to the + # public https URL on the SSO subdomain of the LDAP domain; override with + # OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because + # compose v1 doesn't interpolate nested ${VAR:-...} defaults. + export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}" + export app_name="${app_name:-$ORG_NAME}" -# SMTP (optional). If no user/pass, disable auth by clearing the user. -export app_smtp__host="${app_smtp__host:-${SMTP_HOST:-localhost}}" -export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}" -if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then - export app_smtp__user="${app_smtp__user:-$SMTP_USER}" - export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}" -else - export app_smtp__user="${app_smtp__user:-}" + # SMTP (optional). If no user/pass, disable auth by clearing the user. + export app_smtp__host="${app_smtp__host:-${SMTP_HOST:-localhost}}" + export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}" + if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then + export app_smtp__user="${app_smtp__user:-$SMTP_USER}" + export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}" + else + export app_smtp__user="${app_smtp__user:-}" + fi fi # HTTP port for the app (bin/www reads NODE_PORT). diff --git a/docs/deployment.md b/docs/deployment.md index 3719e87..047c6fc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -105,17 +105,21 @@ The bundled slapd generates a **self-signed cert** on first start (CN = > Port 389 (plain LDAP) is **not** mapped to the host by default — direct-LDAP > clients should use LDAPS (636) or StartTLS. -### Backups (~100 users) +### Backups and restore + +LDAP, Redis (OAuth clients + tokens), and the `./config/` secrets are all +persisted and restorable. Redis is now AOF+RDB persisted to the `sso-data` +volume (not in-memory) so OAuth clients survive rebuilds. + +See the **Backups and restore** section of `DEPLOYMENT.md` for the full runbook +(what lives where, manual backup, full / Redis-only / LDAP-only restore, and +the AOF-vs-RDB note). Quick LDAP backup: ```bash docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif ``` -Restorable with `ldapadd`/`ldapmodify` against a fresh instance. Redis is -in-memory (session/cache only — safe to lose). Persist `JWT_SECRET` + -`LDAP_ADMIN_PASS` outside the container (your `.env`, a password manager). - ## Method 2: Bare metal (Debian/Ubuntu) `install.sh` is an idempotent installer: Node.js 20.x, OpenLDAP (modules + diff --git a/docs/ldap.md b/docs/ldap.md index 2c4caaa..f1b2691 100644 --- a/docs/ldap.md +++ b/docs/ldap.md @@ -123,14 +123,33 @@ idempotently against a running slapd (auto-detects the database holding your base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's active/inactive toggle depends on). -## Backups +## Backups and restore + +**Backup** (while slapd is running): ```bash docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif ``` -Restorable with `ldapadd`/`ldapmodify` against a fresh instance. +Store the `.ldif` off the host — it contains every user's password hash. + +**Restore** into a stopped directory. The SSO image uses a static `slapd.conf` +(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`: + +```bash +docker compose stop sso-manager +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' \ + < ldap-backup-.ldif +docker compose start sso-manager +``` + +Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. + +Redis state (OAuth clients, tokens) and `./config/` secrets are backed up +separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the +full (LDAP + Redis + secrets) runbook. ## Troubleshooting diff --git a/secrets.js.example b/secrets.js.example index 606ead9..22317fc 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -1,12 +1,20 @@ 'use strict'; -// Example secrets configuration file (file-based config, for non-Docker use). -// Copy to nodejs/conf/secrets.js and fill in your values. +// Example secrets configuration file (file-based config). +// +// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. +// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount +// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks +// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. // -// Docker users: you usually don't need this file — pass `app_*` env vars instead -// (see DEPLOYMENT.md). This file is for bare-metal / mounted-config deployments. // Values here override conf/base.js and win over .js. `app_*` env -// vars (if any are set) override this file too. +// vars (if any are set) override this file too — so the Docker stack passes NO +// app_* env, keeping this file authoritative. +// +// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth). +// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read +// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh) +// and ignored by the app — safe to leave them out for bare-metal use. module.exports = { port: 3001, @@ -39,4 +47,21 @@ module.exports = { refresh_token: 2592000 // 30 days in seconds } }, + + // ── Orchestrator-only keys (ignored by the app) ────────────────────────── + // Read by docker-entrypoint.sh (server-side slapd config + validation), the + // superproject bootstrap script, and setup.sh. Omit for bare-metal use. + stack: { + ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs) + ldapDomain: 'example.com', // default cert CN + OAuth issuer host + ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain + ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL) + proxyHost: 'proxy.example.com', // public proxy hostname + }, + bootstrap: { + adminUid: 'admin', // initial SSO admin username + adminPass: 'change-me', // initial SSO admin password + adminEmail: 'admin@example.com', // initial SSO admin email + }, + serviceAccountPass: 'change-me', // LDAP password the proxy binds with }; \ No newline at end of file