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:
+113
-42
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+23
-45
@@ -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
|
||||||
@@ -91,3 +68,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
ldap-data:
|
ldap-data:
|
||||||
ldap-certs:
|
ldap-certs:
|
||||||
|
sso-data:
|
||||||
+86
-31
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user