From 3e87ad86ab9e355f64ab227c26520118859ed172 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 18 Jul 2026 00:51:03 -0400 Subject: [PATCH] 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 --- DEPLOYMENT.md | 74 +-- README.md | 27 +- install.sh | 915 ++++++++------------------------ ops/ldap-setup.sh | 4 +- ops/systemd/sso-manager.service | 10 +- secrets.js.example | 10 +- 6 files changed, 285 insertions(+), 755 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 867fbc3..5532377 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -332,10 +332,17 @@ OAuth clients live in SSO Redis and are preserved by the volume. ## Method 2: Bare metal (Debian/Ubuntu) -`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and -configures OpenLDAP (modules + overlays + custom schema + directory tree + -required groups), deploys the app to `/opt/sso-manager`, and creates a systemd -unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based). +`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis, +force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd +config from the repo. Re-run it to update — it prints the version you're +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 @@ -346,47 +353,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based ### Install ```bash -sudo ./install.sh \ - -p 'your-ldap-password' \ - -b 'dc=yourdomain,dc=com' \ - -n 'Your Org' \ - -o 3001 +wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash ``` -| Flag | Env var | Description | -|------|---------|-------------| -| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) | -| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) | -| `-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) | -| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` | -| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) | -| `--skip-app` | `SKIP_APP` | LDAP setup only | -| `--dry-run` | `DRY_RUN` | Show actions without making changes | +or, if you already have the repo checked out: + +```bash +sudo ./install.sh +``` + +| Env var | Description | +|---------|-------------| +| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only | +| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only | +| `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 ```bash -sudo systemctl enable --now sso-manager +sudo systemctl status sso-manager journalctl -fu sso-manager curl http://localhost:3001/health # -> {"status":"ok"} ``` ### What `install.sh` does -1. Installs Node.js 20.x (NodeSource). -2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint` - modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes; - `ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups. -3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`. -4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults). -5. Installs `sso-manager.service` (systemd), enabled on boot. +1. Installs Node.js 22.x (NodeSource) and Redis. +2. Clones/updates the repo at `/opt/theta42/sso-manager`. +3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`, + `memberof`, `refint` modules + overlays; the custom `theta42Person` schema + (`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default + `pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`. +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 -> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use -> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer -> `ops/ldap-setup.sh` (idempotent, auto-detects the user database). +> For an existing LDAP server, run with `SKIP_LDAP=true` and write +> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before +> starting the service. To (re)configure overlays on an already-installed +> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user +> database). --- diff --git a/README.md b/README.md index 4af0d8c..dd61dc5 100755 --- a/README.md +++ b/README.md @@ -107,23 +107,24 @@ vars, LDAPS/TLS, and backups. ### 3. Bare metal on Debian/Ubuntu -`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP, -configures the directory (modules, overlays, schema, the SSO groups), deploys -the app to `/opt/sso-manager`, and creates a systemd unit. - -The only thing it requires is the LDAP admin password; the domain (base DN) -defaults to `dc=example,dc=com` if you don't pass one: +An automated installer installs Node.js, Redis, and (on first run) OpenLDAP — +configuring the directory (modules, overlays, schema, the SSO groups) and +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 +systemd service: ```bash -sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -sudo systemctl enable --now sso-manager -curl http://localhost:3001/health # -> {"status":"ok"} +wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash ``` -Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT -secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run -it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: -Bare metal*. +That's it — LDAP and the app are both live afterward. Edit +`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and +restart the service to customize. It's idempotent and safe to re-run — +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 diff --git a/install.sh b/install.sh index 5a7d1d0..6c9d404 100755 --- a/install.sh +++ b/install.sh @@ -1,719 +1,230 @@ #!/usr/bin/env bash -# install.sh - Idempotent standalone installer for Theta42 SSO Manager -# For Debian/Ubuntu systems # -# This script: -# 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 +# Install / update Theta42 SSO Manager on a fresh or existing host. # -# Usage: -# sudo ./install.sh [OPTIONS] +# This script is idempotent: run it to install, and re-run it to update. It +# 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: -# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env) -# -b, --base-dn DN Base DN (default: dc=example,dc=com) -# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager) -# -o, --port PORT HTTP port for SSO Manager (default: 3001) -# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated) -# -s, --smtp-config CONFIG SMTP config as host:port:user:pass -# --skip-ldap Skip LDAP installation (use existing LDAP) -# --skip-app Skip application installation (LDAP setup only) -# --dry-run Show what would be done without making changes -# -h, --help Show this help +# Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default), +# outside the repo checkout so they survive the hard reset below. FIRST RUN +# ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules, +# overlays, custom schema, directory tree, required SSO groups -- see +# ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless +# given via env, and seeds $SECRETS_FILE with those values plus SMTP +# placeholders. Edit that file (SMTP, org name, ...) and re-run this script to +# apply changes -- once it exists it is never touched again, and LDAP is never +# re-bootstrapped. # -# Environment variables (alternative to flags): -# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_* - +# Intended to be driven by CI/CD with no human writes on prod: the checkout is +# 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 +# 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 ────────────────────────────────────────────────────────────────── -BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}" -ADMIN_PASS="${LDAP_ADMIN_PASS:-}" +REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}" +REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}" +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}" 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_APP="${SKIP_APP:-false}" -DRY_RUN="${DRY_RUN:-false}" -INSTALL_DIR="/opt/sso-manager" -SYSTEMD_DIR="/etc/systemd/system" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# 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 +if [ "$(id -u)" -ne 0 ]; then + echo "This script must be run as root (try: sudo $0)" >&2 + exit 1 fi -# Generate JWT secret if not provided -if [[ -z "$JWT_SECRET" ]]; then - JWT_SECRET=$(openssl rand -hex 32) - info "Generated JWT secret: ${JWT_SECRET:0:8}..." +# Symlink $1 -> $2, replacing whatever is already at $2 (idempotent). +link(){ + ln -sfn "$1" "$2" + 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 -# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email -# sender defaults. Override with LDAP_DOMAIN if set. -if [[ -z "${LDAP_DOMAIN:-}" ]]; then - LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g') +# FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever +# happen once, the first time this script runs on a host (i.e. before +# $SECRETS_FILE exists). Every later run only updates the code. +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 -# ── System checks ───────────────────────────────────────────────────────────── -check_root() { - if [[ $EUID -ne 0 ]]; then - error "This script must be run as root (sudo)" - exit 1 - fi -} - -check_os() { - if [[ ! -f /etc/debian_version ]]; then - error "This script is for Debian/Ubuntu systems only" - exit 1 - fi - info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" -} - -# ── Package installation ────────────────────────────────────────────────────── -install_package() { - local pkg="$1" - if dpkg -l | grep -q "^ii $pkg "; then - info "Package $pkg is already installed" - return 0 - fi - dry_run "Would install package: $pkg" - [[ "$DRY_RUN" == "true" ]] && return 0 - apt-get update -qq - apt-get install -y -qq "$pkg" - info "Installed $pkg" -} - -install_nodejs() { - if command -v node &>/dev/null && node --version | grep -q "v20"; then - info "Node.js 20.x is already installed" - return 0 - fi - dry_run "Would install Node.js 20.x" - [[ "$DRY_RUN" == "true" ]] && return 0 - - info "Installing Node.js 20.x..." - # Use NodeSource repository for Node.js 20.x - apt-get update -qq - apt-get install -y -qq curl gnupg ca-certificates - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 - apt-get install -y -qq nodejs - info "Installed Node.js $(node --version)" -} - -# ── OpenLDAP installation and configuration ─────────────────────────────────── -install_openldap() { - if command -v slapd &>/dev/null; then - info "OpenLDAP is already installed" - return 0 - fi - dry_run "Would install OpenLDAP" - [[ "$DRY_RUN" == "true" ]] && return 0 - - info "Installing OpenLDAP..." - - # Pre-seed debconf for non-interactive installation - debconf-set-selections << EOF -slapd slapd/internal/adminpw string $ADMIN_PASS -slapd slapd/password1 string $ADMIN_PASS -slapd slapd/password2 string $ADMIN_PASS -slapd slapd/domain string ${BASE_DN#dc=} -slapd slapd/backend string MDB -slapd shared/organization string $ORG_NAME -slapd slapd/purge_database boolean true -slapd slapd/move_old_database boolean true -slapd slapd/invalid_config boolean true -EOF - - apt-get update -qq - apt-get install -y -qq slapd ldap-utils - - # Configure ldap.conf - cat > /etc/ldap/ldap.conf << LDAPCONF -BASE $BASE_DN -URI ldap://localhost -LDAPCONF - - # Set proper permissions - chmod 644 /etc/ldap/ldap.conf - - info "OpenLDAP installed" -} - -configure_openldap() { - info "Configuring OpenLDAP..." - dry_run "Would configure OpenLDAP with base DN: $BASE_DN" - [[ "$DRY_RUN" == "true" ]] && return 0 - - # Wait for slapd to be ready - for i in {1..10}; do - if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then - info "OpenLDAP is ready" - break - fi - sleep 1 - done - - # Detect the database DN for our suffix - DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ - "(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \ - | grep "^dn:" | head -1 | sed 's/^dn: //') - - if [[ -z "$DB_DN" ]]; then - # Try to find any database and update its suffix - DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ - "(objectClass=olcDatabaseConfig)" dn 2>/dev/null \ - | grep "^dn:" | head -1 | sed 's/^dn: //') - - if [[ -n "$DB_DN" ]]; then - info "Updating database suffix to $BASE_DN" - ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF -dn: $DB_DN -changetype: modify -replace: olcSuffix -olcSuffix: $BASE_DN -EOF - fi - fi - - 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} ', - }, - 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 ', - }, -}; -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 +NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")" + +if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then + echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})" + LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}" + 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 + # DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw + # DN with only the leading "dc=" stripped) makes slapd's postinst hang + # indefinitely instead of failing cleanly. + LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')" + + if ! command -v slapd >/dev/null 2>&1; then + debconf-set-selections <<-EOF + slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS} + slapd slapd/password1 password ${LDAP_ADMIN_PASS} + slapd slapd/password2 password ${LDAP_ADMIN_PASS} + slapd slapd/domain string ${LDAP_DOMAIN} + slapd shared/organization string ${ORG_NAME} + slapd slapd/purge_database boolean true + slapd slapd/move_old_database boolean true + EOF + apt-get install -y slapd ldap-utils + cat > /etc/ldap/ldap.conf <<-EOF + BASE ${LDAP_BASE_DN} + URI ldap://localhost + EOF + systemctl enable --now slapd + else + echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}" + fi + + echo "==> Directory structure (ou=people, ou=groups)" + for ou in people groups; do + dn="ou=${ou},${LDAP_BASE_DN}" + 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" + else + ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF + dn: ${dn} + objectClass: organizationalUnit + ou: ${ou} + EOF + echo " ${dn} created" + fi + done + + echo "==> LDAP modules, overlays, schema, policy, SSO groups" + "$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN" + + echo "==> Seeding ${SECRETS_FILE}" + install -d -m 0750 "$(dirname "$SECRETS_FILE")" + cat > "$SECRETS_FILE" <<-SECRETSEOF + 'use strict'; + + // 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. + // LDAP admin password + JWT secret below were auto-generated; SMTP is a + // placeholder (email delivery won't work until you fill it in). + + module.exports = { + port: ${PORT}, + name: '${ORG_NAME}', + ldap: { + url: 'ldap://localhost', + bindDN: '${BIND_DN}', + bindPassword: '${LDAP_ADMIN_PASS}', + userBase: 'ou=people,${LDAP_BASE_DN}', + groupBase: 'ou=groups,${LDAP_BASE_DN}', + }, + smtp: { + host: 'smtp.example.com', + port: 587, + secure: false, + user: 'noreply@${LDAP_DOMAIN}', + pass: 'set-me', + from: '${ORG_NAME} ', + }, + oauth: { + issuer: '', + jwtSecret: '${JWT_SECRET}', + token_lifetime: { + access_token: 3600, + refresh_token: 2592000, + }, + }, + }; + SECRETSEOF + chmod 600 "$SECRETS_FILE" + echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)" + echo " \$EDITOR ${SECRETS_FILE}" + echo " then re-run this script (or: sudo systemctl restart sso-manager)" +elif [ "$FIRST_RUN" -eq 1 ]; then + echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}" + echo " Write it yourself (see secrets.js.example) before starting sso-manager." +else + echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched" +fi + +echo "==> Symlink systemd config from the repo" +link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service + +echo "==> Node dependencies" +# Deterministic, production-only install from the lockfile. Falls back to a +# plain install if the lockfile and manifest are out of step. +( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } ) + +echo "==> Services" +systemctl daemon-reload +systemctl enable --now sso-manager.service +systemctl restart sso-manager.service + +echo "==> Done." +if [ -z "$CURRENT_VERSION" ]; then + echo " Installed v${NEW_VERSION}." +elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then + echo " Already up to date (v${NEW_VERSION})." +else + echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}." +fi +echo " Update later with: sudo BRANCH=${BRANCH} $0" diff --git a/ops/ldap-setup.sh b/ops/ldap-setup.sh index 7b5558c..cf1ff2a 100755 --- a/ops/ldap-setup.sh +++ b/ops/ldap-setup.sh @@ -129,7 +129,7 @@ fi # ── 3. 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}" else config_add "dn: olcOverlay=ppolicy,${DB_DN} @@ -280,7 +280,7 @@ info "verifying ppolicy is active on ${DB_DN}" 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" else warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail" diff --git a/ops/systemd/sso-manager.service b/ops/systemd/sso-manager.service index 9ee4882..edb8327 100644 --- a/ops/systemd/sso-manager.service +++ b/ops/systemd/sso-manager.service @@ -1,6 +1,7 @@ [Unit] -Description=SSO NodeJS manager Service -After=network.target +Description=Theta42 SSO Manager +After=network.target slapd.service +Wants=slapd.service StartLimitIntervalSec=0 [Service] @@ -8,7 +9,10 @@ Type=simple Restart=always RestartSec=1 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] WantedBy=multi-user.target diff --git a/secrets.js.example b/secrets.js.example index 2c4c767..9ad7a86 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -2,10 +2,14 @@ // 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 -// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks -// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. +// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points +// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it. // // Values here override conf/base.js and win over .js. `app_*` env // vars (if any are set) override this file too — so the Docker stack passes NO