Persist Redis (AOF+vol), read config from mounted secrets.js, add backup/restore docs (#34)

Lossless upgrades + config story for the all-in-one image.

Redis persistence (Part A):
- Replace in-memory `--save "" --appendonly no` with AOF + RDB persisted to /data
  (--appendonly yes, periodic saves, --dbfilename dump.rdb). OAuth clients,
  tokens, and other model-redis state now survive container recreation.
- Add the `sso-data` named volume -> /data in docker-compose.yml.

Config from ./config/sso-secrets.js (Part B):
- docker-entrypoint.sh: when /config/sso-secrets.js is mounted, symlink it to
  /app/conf/secrets.js and read the server-side LDAP vars (base DN, admin pass,
  org, domain, cert CN, JWT) from the file via one `node` call (base64-decoded,
  no eval/quoting hazards). No app_* env is exported in this mode, so the file
  is authoritative (@simpleworkjs/conf precedence: base < env < secrets.js <
  app_* env). Falls back to the existing LDAP_* env-var mode when the file is
  absent (standalone/bare-metal still works).
- docker-compose.yml: trim `environment:` to NODE_ENV/NODE_PORT only and add
  `./config:/config:ro`. Removing the app_* env is required — any leftover
  app_* would silently override secrets.js.
- secrets.js.example: add orchestrator-only `stack`, `bootstrap`, and
  `serviceAccountPass` keys (ignored by the app; read by the entrypoint, the
  theta-env bootstrap, and setup.sh).

Backup/restore docs:
- Full "Backups and restore" runbook in DEPLOYMENT.md (what lives where,
  manual backup, full / Redis-only / LDAP-only restore, AOF-vs-RDB note,
  upgrades). Restore uses slapadd -f (static slapd.conf), and RDB restore
  requires deleting the AOF first (AOF wins on startup).
- Pointers in docs/deployment.md and docs/ldap.md; update the Docker Setup
  section for the new ./config/ approach (env vars now advanced/optional).

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-12 12:55:22 -04:00
committed by GitHub
parent d5e951fa9a
commit 6920a9f9f0
6 changed files with 283 additions and 131 deletions
+113 -42
View File
@@ -45,53 +45,60 @@ need to set a few secrets.
### Setup ### 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 ```bash
# Minimal: set an LDAP admin password and a JWT secret, then build + start. mkdir -p config && chmod 700 config
LDAP_ADMIN_PASS='choose-a-strong-password' \ cp secrets.js.example config/sso-secrets.js
JWT_SECRET="$(openssl rand -hex 32)" \ $EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
docker compose up -d --build docker compose up -d --build
``` ```
For a customized deployment, put the overrides in a `.env` file next to `docker-entrypoint.sh` symlinks `/config/sso-secrets.js``/app/conf/secrets.js`
`docker-compose.yml`: 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 > Running the unified `theta-env` stack? Its `setup.sh` generates
LDAP_BASE_DN=dc=yourdomain,dc=com > `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`) for you with random
LDAP_DOMAIN=yourdomain.com > secrets and snapshots state before rebuilds — see the theta-env README.
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 <noreply@yourdomain.com>
PORT=3001
LDAPS_PORT=636
# LDAP_PORT=389 # uncomment the 389 host mapping in compose if you need plain LAN binds
```
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 ### What the entrypoint does
`docker-entrypoint.sh` (run as the container entrypoint): `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` `LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb`
database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS, database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS,
indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf` indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf`
listening on `ldap:///` (389) and `ldaps:///` (636). 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`, `pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`,
`app_sso_oauth_admin`) — idempotently, so container restarts are safe. `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). `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 5. In env mode, exports `app_*` env vars so the app binds to the local slapd. In
set in the compose environment wins over the entrypoint's defaults). secrets.js mode it exports none (the app reads the file directly).
5. `exec`s `node bin/www`. 6. `exec`s `node bin/www`.
### Access ### 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. 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. `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). **What lives where**
Back up the directory with an `ldapsearch`/`slapcat` export on a schedule:
```bash | State | Location | Persisted? |
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ |-------|----------|------------|
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif | LDAP directory (users, groups, policies) | `ldap-data` volume (`/var/lib/ldap`) | yes (volume) |
``` | LDAP TLS cert | `ldap-certs` volume (`/etc/openldap/certs`) | yes (volume) |
(Restorable with `ldapadd`/`ldapmodify` against a fresh instance.) | Redis (OAuth clients, tokens, sessions) | `sso-data` volume (`/data`) | yes (AOF + RDB) |
- **Redis** is in-memory and not persisted by default (session/cache only — safe | Secrets (LDAP admin pass, JWT secret, SMTP) | `./config/sso-secrets.js` (bind mount) | your responsibility — back up off-host |
to lose). If you want session durability, mount a Redis AOF/RDB volume and
enable persistence in `docker-entrypoint.sh`. **Automatic snapshots** — when run as part of the unified `theta-env` stack,
- **JWT_SECRET** and **LDAP_ADMIN_PASS** are operational secrets — store them `setup.sh` snapshots LDAP + Redis + `./config/` to `./backups/<timestamp>/`
outside the container (your `.env`, a password manager, etc.). 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-<date> ./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-<date>.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-<date>.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.
--- ---
+24 -46
View File
@@ -1,18 +1,20 @@
# Docker Compose for the SSO Manager all-in-one image (app + OpenLDAP in one container). # 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 # The image (Dockerfile.openldap) bundles a slapd that the app talks to over
# localhost. Configuration is supplied to the app via `app_*` environment # localhost. The app reads its configuration from conf/base.js + conf/secrets.js
# variables — the highest-precedence config layer in @simpleworkjs/conf. Any # (deep-merged by @simpleworkjs/conf). The operator-edited secrets live in a
# `app_*` var not set here falls back to a sensible default baked into # bind-mounted ./config/sso-secrets.js, which docker-entrypoint.sh symlinks into
# docker-entrypoint.sh (e.g. app_ldap__url -> ldap://localhost:389). # /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 # Compose only interpolates the port defaults below — there is no .env file.
# set them inline on the command line: # Override a port on the command line if needed:
# LDAP_ADMIN_PASS=... JWT_SECRET=... docker compose up -d # 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 # 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: services:
sso-manager: sso-manager:
@@ -34,45 +36,16 @@ services:
# plain binds from the LAN (not recommended): # plain binds from the LAN (not recommended):
# - "${LDAP_PORT:-389}:389" # - "${LDAP_PORT:-389}:389"
environment: environment:
# ── LDAP server-side (configures the bundled slapd) ── # Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see
- LDAP_BASE_DN=${LDAP_BASE_DN:-dc=example,dc=com} # volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app
- LDAP_DOMAIN=${LDAP_DOMAIN:-example.com} # reads that are not part of its conf tree.
- 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.<LDAP_DOMAIN> (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 <noreply@example.com>}
- NODE_ENV=production - NODE_ENV=production
- NODE_PORT=3001 - NODE_PORT=3001
volumes: 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. # Persist the LDAP database across container recreation.
- ldap-data:/var/lib/ldap - ldap-data:/var/lib/ldap
# Persist the auto-generated self-signed TLS cert so clients don't have to # Persist the auto-generated self-signed TLS cert so clients don't have to
@@ -81,6 +54,10 @@ services:
# - ./certs:/etc/openldap/certs # - ./certs:/etc/openldap/certs
# (must contain ldap.crt + ldap.key; the entrypoint leaves them untouched). # (must contain ldap.crt + ldap.key; the entrypoint leaves them untouched).
- ldap-certs:/etc/openldap/certs - 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: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
interval: 30s interval: 30s
@@ -90,4 +67,5 @@ services:
volumes: volumes:
ldap-data: ldap-data:
ldap-certs: ldap-certs:
sso-data:
+86 -31
View File
@@ -30,6 +30,48 @@ APP_LDAP_URL="${app_ldap__url:-ldap://localhost:389}"
info() { echo "[INFO] $*"; } info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; } 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 `KEY<TAB>base64(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 < <env>.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 ──────────────────────────────────── # ── Locate the OpenLDAP module directory ────────────────────────────────────
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The # slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
# path varies by distro; auto-detect rather than hardcode. # path varies by distro; auto-detect rather than hardcode.
@@ -248,13 +290,21 @@ else
fi fi
# ── Start Redis ────────────────────────────────────────────────────────────── # ── Start Redis ──────────────────────────────────────────────────────────────
# The app stores models/sessions in Redis (model-redis). The all-in-one image # The app stores models/sessions in Redis (model-redis), and the SSO's
# bundles a Redis server for a self-contained single-node deployment. Point the # non-bootstrap OAuth clients also live there. The all-in-one image bundles a
# app at an external Redis instead by setting app_redis__host before starting. # Redis server for a self-contained single-node deployment. Persist it to /data
# In-memory, no persistence: cache/session data is rebuilt on restart. # (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 if [[ -z "${app_redis__host:-}" ]]; then
info "Starting Redis..." REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
redis-server --save "" --appendonly no --daemonize no & 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=$! REDIS_PID=$!
for i in $(seq 1 15); do for i in $(seq 1 15); do
if redis-cli ping >/dev/null 2>&1; then 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. # is needed when running the bundled Redis.
fi fi
# ── Generate a JWT secret if none was provided ────────────────────────────── # ── Generate a JWT secret if none was provided (env mode only) ──────────────
if [[ -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then # 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 if command -v openssl >/dev/null 2>&1; then
JWT_SECRET=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 32)
else 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)" info "Generated JWT secret (set JWT_SECRET or app_oauth__jwtSecret to persist)"
fi fi
# ── Export app_* config overrides for the SSO Manager process ──────────────── # ── Export app_* config overrides for the SSO Manager process (env mode) ─────
# These are the highest-precedence config layer in @simpleworkjs/conf. Any # In secrets.js mode the app reads /app/conf/secrets.js directly, so we export
# value already set in the environment is preserved (${VAR:-default}). # NO app_* vars — they would override the file (@simpleworkjs/conf precedence:
export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}" # base.js < <env>.js < secrets.js < app_* env). In env mode these remain the
export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}" # highest-precedence layer, derived from the LDAP_* / ORG_NAME / SMTP_* env.
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}" if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}" export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}"
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}" export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}"
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}" export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
# public https URL on the SSO subdomain of the LDAP domain; override with export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
# OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
# compose v1 doesn't interpolate nested ${VAR:-...} defaults. # OIDC issuer advertised in /.well-known/openid-configuration. Default to the
export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}" # public https URL on the SSO subdomain of the LDAP domain; override with
export app_name="${app_name:-$ORG_NAME}" # 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. # 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__host="${app_smtp__host:-${SMTP_HOST:-localhost}}"
export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}" export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}"
if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then
export app_smtp__user="${app_smtp__user:-$SMTP_USER}" export app_smtp__user="${app_smtp__user:-$SMTP_USER}"
export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}" export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}"
else else
export app_smtp__user="${app_smtp__user:-}" export app_smtp__user="${app_smtp__user:-}"
fi
fi fi
# HTTP port for the app (bin/www reads NODE_PORT). # HTTP port for the app (bin/www reads NODE_PORT).
+9 -5
View File
@@ -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 > Port 389 (plain LDAP) is **not** mapped to the host by default — direct-LDAP
> clients should use LDAPS (636) or StartTLS. > 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 ```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif -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) ## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: Node.js 20.x, OpenLDAP (modules + `install.sh` is an idempotent installer: Node.js 20.x, OpenLDAP (modules +
+21 -2
View File
@@ -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 base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's
active/inactive toggle depends on). active/inactive toggle depends on).
## Backups ## Backups and restore
**Backup** (while slapd is running):
```bash ```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif -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-<date>.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 ## Troubleshooting
+30 -5
View File
@@ -1,12 +1,20 @@
'use strict'; 'use strict';
// Example secrets configuration file (file-based config, for non-Docker use). // Example secrets configuration file (file-based config).
// Copy to nodejs/conf/secrets.js and fill in your values. //
// 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 <environment>.js. `app_*` env // Values here override conf/base.js and win over <environment>.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 = { module.exports = {
port: 3001, port: 3001,
@@ -39,4 +47,21 @@ module.exports = {
refresh_token: 2592000 // 30 days in seconds 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
}; };