Rewrite install.sh as a git-clone installer, add a one-line install

Replaces the old flag-driven, copy-based installer with an idempotent
git-clone-and-symlink installer matching theta42/proxy's ops/install.sh
pattern, so `wget -O - .../install.sh | sudo bash` works the same way
for both apps:

- Installs to /opt/theta42/sso-manager (was /opt/sso-manager, and the
  repo had to already be checked out locally -- now it clones itself).
- First run only: bootstraps OpenLDAP (modules, overlays, schema,
  directory tree, SSO groups -- ops/ldap-setup.sh) with a generated
  admin password + JWT secret, and seeds /etc/sso-manager/secrets.js
  (was /opt/sso-manager/conf/secrets.js, hand-filled from CLI flags).
  Later runs never touch LDAP or the secrets file again.
- ops/systemd/sso-manager.service now points at the new install path
  and sets CONF_SECRETS=/etc/sso-manager/secrets.js (requires
  @simpleworkjs/conf >= 1.2.0, already the pinned version) instead of
  the app needing a config file inside the repo checkout.
- Prints the version it's updating from/to (or "Already up to date")
  on every run, instead of updating silently.

Two real bugs found and fixed while testing this end-to-end in a clean
container:
- The debconf `slapd/domain` value was computed as
  `${LDAP_BASE_DN#dc=}` ("example,dc=com" for "dc=example,dc=com")
  instead of a proper dotted domain -- slapd's postinst hangs
  indefinitely on a malformed domain instead of failing cleanly.
  Fixed to derive it the same way the secrets file already did
  ("example.com").
- ops/ldap-setup.sh's ppolicy-overlay checks used an LDAP substring
  filter, `(olcOverlay=*ppolicy*)`, against an attribute that doesn't
  support substring matching -- it silently matched nothing even when
  the overlay was correctly configured (stored as "{0}ppolicy"),
  so the final verification always reported failure and `set -e`
  aborted the installer after LDAP was set up but before the app was.
  Fixed to filter on `(objectClass=olcOverlayConfig)` and let the
  existing DN-based grep narrow it down, matching the pattern already
  used by every other check in that script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:51:03 -04:00
parent c5a2c0a71d
commit 3e87ad86ab
6 changed files with 285 additions and 755 deletions
+42 -32
View File
@@ -332,10 +332,17 @@ OAuth clients live in SSO Redis and are preserved by the volume.
## Method 2: Bare metal (Debian/Ubuntu) ## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and `install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
configures OpenLDAP (modules + overlays + custom schema + directory tree + force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd config from the repo. Re-run it to update — it prints the version you're
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based). updating from and to (or "Already up to date" if there's nothing new).
On the **first run only** it also installs and configures OpenLDAP (modules +
overlays + custom schema + directory tree + required groups — see
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
file exists it's never touched again, and LDAP is never re-bootstrapped —
edit the file and restart the service to change anything.
### Prerequisites ### Prerequisites
@@ -346,47 +353,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
### Install ### Install
```bash ```bash
sudo ./install.sh \ wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
``` ```
| Flag | Env var | Description | or, if you already have the repo checked out:
|------|---------|-------------|
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) | ```bash
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) | sudo ./install.sh
| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) | ```
| `-o, --port` | `PORT` | HTTP port (default `3001`) |
| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) | | Env var | Description |
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` | |---------|-------------|
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) | | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
| `--skip-app` | `SKIP_APP` | LDAP setup only | | `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
| `--dry-run` | `DRY_RUN` | Show actions without making changes | | `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
| `PORT` | HTTP port (default `3001`) — first run only |
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
### Post-install ### Post-install
```bash ```bash
sudo systemctl enable --now sso-manager sudo systemctl status sso-manager
journalctl -fu sso-manager journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"} curl http://localhost:3001/health # -> {"status":"ok"}
``` ```
### What `install.sh` does ### What `install.sh` does
1. Installs Node.js 20.x (NodeSource). 1. Installs Node.js 22.x (NodeSource) and Redis.
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint` 2. Clones/updates the repo at `/opt/theta42/sso-manager`.
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes; 3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups. `memberof`, `refint` modules + overlays; the custom `theta42Person` schema
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`. (`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults). `pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
5. Installs `sso-manager.service` (systemd), enabled on boot. 4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
runs `npm ci --omit=dev`.
5. Enables and (re)starts the service.
> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the > For an existing LDAP server, run with `SKIP_LDAP=true` and write
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use > `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer > starting the service. To (re)configure overlays on an already-installed
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database). > slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
> database).
--- ---
+14 -13
View File
@@ -107,23 +107,24 @@ vars, LDAPS/TLS, and backups.
### 3. Bare metal on Debian/Ubuntu ### 3. Bare metal on Debian/Ubuntu
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP, An automated installer installs Node.js, Redis, and (on first run) OpenLDAP
configures the directory (modules, overlays, schema, the SSO groups), deploys configuring the directory (modules, overlays, schema, the SSO groups) and
the app to `/opt/sso-manager`, and creates a systemd unit. seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
The only thing it requires is the LDAP admin password; the domain (base DN) systemd service:
defaults to `dc=example,dc=com` if you don't pass one:
```bash ```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
sudo systemctl enable --now sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
``` ```
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT That's it — LDAP and the app are both live afterward. Edit
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run `/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: restart the service to customize. It's idempotent and safe to re-run —
Bare metal*. re-running it updates the app in place (never touching LDAP or the secrets
file again) and prints the version you're updating from and to (e.g. `Updated
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Architecture ## Architecture
+213 -702
View File
@@ -1,719 +1,230 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
# For Debian/Ubuntu systems
# #
# This script: # Install / update Theta42 SSO Manager on a fresh or existing host.
# 1. Installs Node.js 20.x
# 2. Installs and configures OpenLDAP with required schemas/overlays
# 3. Deploys the SSO Manager application
# 4. Sets up systemd services
# #
# Usage: # This script is idempotent: run it to install, and re-run it to update. It
# sudo ./install.sh [OPTIONS] # installs system dependencies (Node, OpenLDAP, Redis), force-syncs the repo at
# $REPO_DIR to its remote branch, and symlinks the systemd config straight from
# the repo. Because the config is symlinked, an update is just "sync the repo +
# restart" -- the files under /etc/systemd always track the repo.
# #
# Options: # Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env) # outside the repo checkout so they survive the hard reset below. FIRST RUN
# -b, --base-dn DN Base DN (default: dc=example,dc=com) # ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager) # overlays, custom schema, directory tree, required SSO groups -- see
# -o, --port PORT HTTP port for SSO Manager (default: 3001) # ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated) # given via env, and seeds $SECRETS_FILE with those values plus SMTP
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass # placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
# --skip-ldap Skip LDAP installation (use existing LDAP) # apply changes -- once it exists it is never touched again, and LDAP is never
# --skip-app Skip application installation (LDAP setup only) # re-bootstrapped.
# --dry-run Show what would be done without making changes
# -h, --help Show this help
# #
# Environment variables (alternative to flags): # Intended to be driven by CI/CD with no human writes on prod: the checkout is
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_* # hard-reset to origin/$BRANCH on every run, so the box deterministically
# mirrors the repo (any drift on the box is discarded).
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=, LDAP_BASE_DN=, LDAP_ADMIN_PASS=,
# JWT_SECRET=, ORG_NAME=, PORT=, SKIP_LDAP=true)
set -euo pipefail set -euo pipefail
# Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency of redis-server/slapd on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
# ── Defaults ────────────────────────────────────────────────────────────────── REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}" REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
ADMIN_PASS="${LDAP_ADMIN_PASS:-}" BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/sso-manager/secrets.js}"
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ORG_NAME="${ORG_NAME:-SSO Manager}" ORG_NAME="${ORG_NAME:-SSO Manager}"
PORT="${PORT:-3001}" PORT="${PORT:-3001}"
JWT_SECRET="${JWT_SECRET:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASS="${SMTP_PASS:-}"
SKIP_LDAP="${SKIP_LDAP:-false}" SKIP_LDAP="${SKIP_LDAP:-false}"
SKIP_APP="${SKIP_APP:-false}"
DRY_RUN="${DRY_RUN:-false}"
INSTALL_DIR="/opt/sso-manager" if [ "$(id -u)" -ne 0 ]; then
SYSTEMD_DIR="/etc/systemd/system" echo "This script must be run as root (try: sudo $0)" >&2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exit 1
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# ── Helper functions ──────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; }
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--admin-pass)
ADMIN_PASS="$2"
shift 2
;;
-b|--base-dn)
BASE_DN="$2"
shift 2
;;
-n|--org-name)
ORG_NAME="$2"
shift 2
;;
-o|--port)
PORT="$2"
shift 2
;;
-j|--jwt-secret)
JWT_SECRET="$2"
shift 2
;;
-s|--smtp-config)
IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2"
shift 2
;;
--skip-ldap)
SKIP_LDAP="true"
shift
;;
--skip-app)
SKIP_APP="true"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
-h|--help)
usage
;;
*)
error "Unknown option: $1"
usage
;;
esac
done
# Validate required parameters
if [[ -z "$ADMIN_PASS" ]]; then
error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)"
exit 1
fi fi
# Generate JWT secret if not provided # Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
if [[ -z "$JWT_SECRET" ]]; then link(){
JWT_SECRET=$(openssl rand -hex 32) ln -sfn "$1" "$2"
info "Generated JWT secret: ${JWT_SECRET:0:8}..." echo "linked $2 -> $1"
}
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi fi
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email # FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
# sender defaults. Override with LDAP_DOMAIN if set. # happen once, the first time this script runs on a host (i.e. before
if [[ -z "${LDAP_DOMAIN:-}" ]]; then # $SECRETS_FILE exists). Every later run only updates the code.
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g') FIRST_RUN=0
[ -f "$SECRETS_FILE" ] || FIRST_RUN=1
echo "==> Base packages"
apt-get update
apt-get install -y --no-install-recommends \
build-essential redis-server \
wget gnupg ca-certificates curl git
echo "==> Node.js ${NODE_MAJOR}.x apt source"
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
echo "==> Install Node.js"
apt-get update
apt-get install -y nodejs
echo "==> Redis"
systemctl enable --now redis-server
echo "==> Repo checkout at ${REPO_DIR} (branch ${BRANCH})"
install -d "$(dirname "$REPO_DIR")"
if [ -d "$REPO_DIR/.git" ]; then
# Force the box to match the remote branch exactly. No human edits configs
# on prod, so discarding local drift is the desired, deterministic behavior.
git -C "$REPO_DIR" fetch --prune origin
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
git -C "$REPO_DIR" clean -fd
else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi fi
# ── System checks ───────────────────────────────────────────────────────────── NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
check_root() {
if [[ $EUID -ne 0 ]]; then if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
error "This script must be run as root (sudo)" echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
exit 1 LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
fi JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
} BIND_DN="cn=admin,${LDAP_BASE_DN}"
# slapd/domain wants a dotted DNS domain (e.g. "example.com"), not the raw
check_os() { # DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
if [[ ! -f /etc/debian_version ]]; then # DN with only the leading "dc=" stripped) makes slapd's postinst hang
error "This script is for Debian/Ubuntu systems only" # indefinitely instead of failing cleanly.
exit 1 LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
fi
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" if ! command -v slapd >/dev/null 2>&1; then
} debconf-set-selections <<-EOF
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
# ── Package installation ────────────────────────────────────────────────────── slapd slapd/password1 password ${LDAP_ADMIN_PASS}
install_package() { slapd slapd/password2 password ${LDAP_ADMIN_PASS}
local pkg="$1" slapd slapd/domain string ${LDAP_DOMAIN}
if dpkg -l | grep -q "^ii $pkg "; then slapd shared/organization string ${ORG_NAME}
info "Package $pkg is already installed" slapd slapd/purge_database boolean true
return 0 slapd slapd/move_old_database boolean true
fi EOF
dry_run "Would install package: $pkg" apt-get install -y slapd ldap-utils
[[ "$DRY_RUN" == "true" ]] && return 0 cat > /etc/ldap/ldap.conf <<-EOF
apt-get update -qq BASE ${LDAP_BASE_DN}
apt-get install -y -qq "$pkg" URI ldap://localhost
info "Installed $pkg" EOF
} systemctl enable --now slapd
else
install_nodejs() { echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
if command -v node &>/dev/null && node --version | grep -q "v20"; then fi
info "Node.js 20.x is already installed"
return 0 echo "==> Directory structure (ou=people, ou=groups)"
fi for ou in people groups; do
dry_run "Would install Node.js 20.x" dn="ou=${ou},${LDAP_BASE_DN}"
[[ "$DRY_RUN" == "true" ]] && return 0 if ldapsearch -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "^dn:"; then
echo " ${dn} already exists"
info "Installing Node.js 20.x..." else
# Use NodeSource repository for Node.js 20.x ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
apt-get update -qq dn: ${dn}
apt-get install -y -qq curl gnupg ca-certificates objectClass: organizationalUnit
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 ou: ${ou}
apt-get install -y -qq nodejs EOF
info "Installed Node.js $(node --version)" echo " ${dn} created"
} fi
done
# ── OpenLDAP installation and configuration ───────────────────────────────────
install_openldap() { echo "==> LDAP modules, overlays, schema, policy, SSO groups"
if command -v slapd &>/dev/null; then "$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
info "OpenLDAP is already installed"
return 0 echo "==> Seeding ${SECRETS_FILE}"
fi install -d -m 0750 "$(dirname "$SECRETS_FILE")"
dry_run "Would install OpenLDAP" cat > "$SECRETS_FILE" <<-SECRETSEOF
[[ "$DRY_RUN" == "true" ]] && return 0 'use strict';
info "Installing OpenLDAP..." // Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely --
// this file is never overwritten by a later run of install.sh.
# Pre-seed debconf for non-interactive installation // LDAP admin password + JWT secret below were auto-generated; SMTP is a
debconf-set-selections << EOF // placeholder (email delivery won't work until you fill it in).
slapd slapd/internal/adminpw string $ADMIN_PASS
slapd slapd/password1 string $ADMIN_PASS module.exports = {
slapd slapd/password2 string $ADMIN_PASS port: ${PORT},
slapd slapd/domain string ${BASE_DN#dc=} name: '${ORG_NAME}',
slapd slapd/backend string MDB ldap: {
slapd shared/organization string $ORG_NAME url: 'ldap://localhost',
slapd slapd/purge_database boolean true bindDN: '${BIND_DN}',
slapd slapd/move_old_database boolean true bindPassword: '${LDAP_ADMIN_PASS}',
slapd slapd/invalid_config boolean true userBase: 'ou=people,${LDAP_BASE_DN}',
EOF groupBase: 'ou=groups,${LDAP_BASE_DN}',
},
apt-get update -qq smtp: {
apt-get install -y -qq slapd ldap-utils host: 'smtp.example.com',
port: 587,
# Configure ldap.conf secure: false,
cat > /etc/ldap/ldap.conf << LDAPCONF user: 'noreply@${LDAP_DOMAIN}',
BASE $BASE_DN pass: 'set-me',
URI ldap://localhost from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
LDAPCONF },
oauth: {
# Set proper permissions issuer: '',
chmod 644 /etc/ldap/ldap.conf jwtSecret: '${JWT_SECRET}',
token_lifetime: {
info "OpenLDAP installed" access_token: 3600,
} refresh_token: 2592000,
},
configure_openldap() { },
info "Configuring OpenLDAP..." };
dry_run "Would configure OpenLDAP with base DN: $BASE_DN" SECRETSEOF
[[ "$DRY_RUN" == "true" ]] && return 0 chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
# Wait for slapd to be ready echo " \$EDITOR ${SECRETS_FILE}"
for i in {1..10}; do echo " then re-run this script (or: sudo systemctl restart sso-manager)"
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then elif [ "$FIRST_RUN" -eq 1 ]; then
info "OpenLDAP is ready" echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
break echo " Write it yourself (see secrets.js.example) before starting sso-manager."
fi else
sleep 1 echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
done fi
# Detect the database DN for our suffix echo "==> Symlink systemd config from the repo"
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //') echo "==> Node dependencies"
# Deterministic, production-only install from the lockfile. Falls back to a
if [[ -z "$DB_DN" ]]; then # plain install if the lockfile and manifest are out of step.
# Try to find any database and update its suffix ( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \ echo "==> Services"
| grep "^dn:" | head -1 | sed 's/^dn: //') systemctl daemon-reload
systemctl enable --now sso-manager.service
if [[ -n "$DB_DN" ]]; then systemctl restart sso-manager.service
info "Updating database suffix to $BASE_DN"
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF echo "==> Done."
dn: $DB_DN if [ -z "$CURRENT_VERSION" ]; then
changetype: modify echo " Installed v${NEW_VERSION}."
replace: olcSuffix elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
olcSuffix: $BASE_DN echo " Already up to date (v${NEW_VERSION})."
EOF else
fi echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
if [[ -z "$DB_DN" ]]; then
error "Could not detect OpenLDAP database configuration"
return 1
fi
info "Using database: $DB_DN"
# 1. Load pw-sha2 module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then
info "Loading pw-sha2 module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: pw-sha2
EOF
else
info "pw-sha2 module already loaded"
fi
# 2. Load ppolicy module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then
info "Loading ppolicy module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
EOF
else
info "ppolicy module already loaded"
fi
# 3. Load memberof module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then
info "Loading memberof module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
EOF
else
info "memberof module already loaded"
fi
# 4. Load refint module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then
info "Loading refint module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: refint
EOF
else
info "refint module already loaded"
fi
# 5. Add ppolicy overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then
info "Adding ppolicy overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=ppolicy,$DB_DN
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicy
olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN
olcPPolicyUseLockout: TRUE
EOF
else
info "ppolicy overlay already configured"
fi
# 6. Add memberof overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then
info "Adding memberof overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=memberof,$DB_DN
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfDangling: ignore
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
EOF
else
info "memberof overlay already configured"
fi
# 7. Add refint overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then
info "Adding refint overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=refint,$DB_DN
objectClass: olcConfig
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
objectClass: top
olcOverlay: refint
olcRefintAttribute: memberof member manager owner
EOF
else
info "refint overlay already configured"
fi
# 8. Add database indexes
info "Configuring database indexes..."
for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do
attr=$(echo "$index" | cut -d' ' -f1)
types=$(echo "$index" | cut -d' ' -f2)
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true
dn: $DB_DN
changetype: modify
add: olcDbIndex
olcDbIndex: $attr $types
EOF
done
# 9. Load custom theta42 schema
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then
info "Loading custom theta42 schema..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=theta42,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: theta42
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
NAME 'dateOfBirth'
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
EQUALITY caseExactMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE )
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
NAME 'theta42Person'
DESC 'Theta42 SSO extended person attributes'
AUXILIARY
MAY ( dateOfBirth ) )
EOF
else
info "theta42 schema already loaded"
fi
# 10. Create base directory structure
BIND_DN="cn=admin,$BASE_DN"
# Create base DN if it doesn't exist
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating base DN structure..."
DC_VALUE="${BASE_DN#dc=}"
DC_VALUE="${DC_VALUE%%,*}"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $BASE_DN
objectClass: dcObject
objectClass: organization
dc: $DC_VALUE
o: $ORG_NAME
EOF
else
info "Base DN already exists"
fi
# Create OUs
for ou in people groups policies; do
dn="ou=$ou,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating $ou OU..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: ou=$ou,$BASE_DN
objectClass: organizationalUnit
ou: $ou
EOF
else
info "OU $ou already exists"
fi
done
# 11. Create default ppolicy
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating default ppolicy..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: cn=ppolicy,ou=policies,$BASE_DN
objectClass: top
objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: FALSE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
else
info "Default ppolicy already exists"
fi
# 12. Create required SSO groups
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
dn="cn=$group,ou=groups,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating group: $group"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $dn
objectClass: groupOfNames
objectClass: top
cn: $group
description: $ORG_NAME $group group
member: $BIND_DN
EOF
else
info "Group $group already exists"
fi
done
info "OpenLDAP configuration complete"
}
# ── Application installation ──────────────────────────────────────────────────
install_app() {
info "Installing SSO Manager application..."
dry_run "Would install application to $INSTALL_DIR"
[[ "$DRY_RUN" == "true" ]] && return 0
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Copy application files
info "Copying application files..."
cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/"
# Install npm dependencies
info "Installing npm dependencies..."
cd "$INSTALL_DIR"
npm ci --only=production --quiet
# Create secrets configuration
info "Creating application configuration..."
cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF
'use strict';
module.exports = {
port: $PORT,
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '$ADMIN_PASS',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
},
smtp: {
host: '${SMTP_HOST:-localhost}',
port: ${SMTP_PORT:-587},
user: '${SMTP_USER:-}',
pass: '${SMTP_PASS:-}',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
voipms: {
username: '${VOIPMS_USER:-}',
password: '${VOIPMS_PASS:-}',
did: '${VOIPMS_DID:-}',
},
oauth: {
issuer: '',
jwtSecret: '$JWT_SECRET',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
};
SECRETEOF
# Create base configuration
cat > "$INSTALL_DIR/conf/base.js" << BASEEOF
'use strict';
module.exports = {
name: "$ORG_NAME",
userModel: 'ldap',
redis: {
prefix: 'sso_manager_'
},
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '__IN SECRETS FILE__',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid'
},
oauth: {
issuer: '',
jwtSecret: '__in secrets file__',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
from: '$ORG_NAME <noreply@$LDAP_DOMAIN>',
},
};
BASEEOF
# Set ownership
chown -R root:root "$INSTALL_DIR"
chmod -R 755 "$INSTALL_DIR"
info "Application installed to $INSTALL_DIR"
}
# ── Systemd service configuration ─────────────────────────────────────────────
install_systemd() {
info "Installing systemd service..."
dry_run "Would install systemd service"
[[ "$DRY_RUN" == "true" ]] && return 0
cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF
[Unit]
Description=Theta42 SSO Manager
Documentation=file://$INSTALL_DIR/README.md
After=network.target slapd.service
Wants=slapd.service
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/bin/www
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=NODE_PORT=$PORT
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload
systemctl enable sso-manager.service
info "Systemd service installed"
}
# ── Verification ──────────────────────────────────────────────────────────────
verify_installation() {
info "Verifying installation..."
local errors=0
# Check OpenLDAP
if command -v slapd &>/dev/null; then
if systemctl is-active --quiet slapd; then
info "✓ OpenLDAP is running"
else
warn "✗ OpenLDAP is not running"
((errors++))
fi
else
warn "✗ OpenLDAP is not installed"
((errors++))
fi
# Check application
if [[ -d "$INSTALL_DIR" ]]; then
info "✓ Application is installed"
else
warn "✗ Application is not installed"
((errors++))
fi
# Check systemd service
if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then
info "✓ Systemd service is enabled"
else
warn "✗ Systemd service is not enabled"
((errors++))
fi
if [[ $errors -eq 0 ]]; then
info "Installation verified successfully"
else
warn "Installation completed with $errors issue(s)"
fi
return $errors
}
# ── Main execution ────────────────────────────────────────────────────────────
main() {
echo
echo "=============================================="
echo " Theta42 SSO Manager Installer"
echo "=============================================="
echo
echo "Configuration:"
echo " Base DN: $BASE_DN"
echo " Port: $PORT"
echo " Install dir: $INSTALL_DIR"
echo " Skip LDAP: $SKIP_LDAP"
echo " Skip App: $SKIP_APP"
echo
check_root
check_os
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "=== Installing OpenLDAP ==="
install_openldap
configure_openldap
fi
if [[ "$SKIP_APP" != "true" ]]; then
echo
info "=== Installing SSO Manager ==="
install_nodejs
install_app
install_systemd
fi
echo
verify_installation
echo
echo "=============================================="
echo " Installation Complete!"
echo "=============================================="
echo
if [[ "$SKIP_APP" != "true" ]]; then
info "Start the service with: systemctl start sso-manager"
info "View logs with: journalctl -fu sso-manager"
info "Access the UI at: http://localhost:$PORT"
fi
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "LDAP Configuration:"
info " Base DN: $BASE_DN"
info " Bind DN: cn=admin,$BASE_DN"
info " Admin pass: (set by you)"
echo
info "Required SSO groups created:"
info " - app_sso_admin"
info " - app_sso_invite"
info " - app_sso_oauth_admin"
fi
echo
}
main
+2 -2
View File
@@ -129,7 +129,7 @@ fi
# ── 3. ppolicy overlay ──────────────────────────────────────────────────────── # ── 3. ppolicy overlay ────────────────────────────────────────────────────────
info "ppolicy overlay" info "ppolicy overlay"
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
skip "ppolicy overlay already configured on ${DB_DN}" skip "ppolicy overlay already configured on ${DB_DN}"
else else
config_add "dn: olcOverlay=ppolicy,${DB_DN} config_add "dn: olcOverlay=ppolicy,${DB_DN}
@@ -280,7 +280,7 @@ info "verifying ppolicy is active on ${DB_DN}"
VERIFY_FAILED=0 VERIFY_FAILED=0
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
ok "ppolicy overlay is attached to the user database" ok "ppolicy overlay is attached to the user database"
else else
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail" warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
+7 -3
View File
@@ -1,6 +1,7 @@
[Unit] [Unit]
Description=SSO NodeJS manager Service Description=Theta42 SSO Manager
After=network.target After=network.target slapd.service
Wants=slapd.service
StartLimitIntervalSec=0 StartLimitIntervalSec=0
[Service] [Service]
@@ -8,7 +9,10 @@ Type=simple
Restart=always Restart=always
RestartSec=1 RestartSec=1
User=root User=root
ExecStart=/usr/bin/env node /var/www/sso-manager-node/nodejs/bin/www WorkingDirectory=/opt/theta42/sso-manager/nodejs
Environment="NODE_ENV=production"
Environment="CONF_SECRETS=/etc/sso-manager/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/sso-manager/nodejs/bin/www
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+7 -3
View File
@@ -2,10 +2,14 @@
// Example secrets configuration file (file-based config). // Example secrets configuration file (file-based config).
// //
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. // Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount // Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks // ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. // the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
// //
// 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 — so the Docker stack passes NO // vars (if any are set) override this file too — so the Docker stack passes NO