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
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 <noreply@yourdomain.com>
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/<timestamp>/`
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).
#
# 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.<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>}
# 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:
ldap-certs:
sso-data:
+86 -31
View File
@@ -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 `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 ────────────────────────────────────
# 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 < <env>.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).
+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
> 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 +
+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
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-<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
+30 -5
View File
@@ -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 <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 = {
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
};