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)
`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).
---
+14 -13
View File
@@ -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
+213 -702
View File
@@ -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} <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
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} <noreply@${LDAP_DOMAIN}>',
},
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"
+2 -2
View File
@@ -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"
+7 -3
View File
@@ -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
+7 -3
View File
@@ -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 <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO