7efc271938
Rolls up sso-manager-node v1.30.0, theta-agent v1.5.1, proxy v1.35.0. The stack's own theta-agent could never connect. setup.sh generated a random token locally and wrote it into agent.yml, but the SSO only accepts credentials it issued, so it was rejected on every attempt and the agent looped on "close 4001: Unauthorized" forever. It now writes a join key the SSO minted; the agent exchanges it for its own token and the SSO public key on first connect and rewrites its own config. agent.yml was also left holding literal REPLACE_WITH_* placeholders once the seds stopped matching the renamed fields, so a fresh install had no credential at all. The file is chmod 600 now that it holds one. A fresh install presented its own five containers as unmanaged discoveries. The compose project name is passed to the Docker discovery plugin, which recognises them and links each to the service it implements. openbao and bao-renewer had no directory entries for their containers to attach to; both are seeded as services now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1423 lines
68 KiB
Bash
Executable File
1423 lines
68 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# theta-suite setup — one-command bring-up of the unified SSO Manager + Proxy stack.
|
|
#
|
|
# git clone --recursive <theta-suite> && cd theta-suite
|
|
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once)
|
|
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
|
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
|
|
#
|
|
# Idempotent: safe to re-run. It pulls its own latest version, updates the two
|
|
# submodules, manages config in a bind-mounted ./config/ directory
|
|
# (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots state
|
|
# before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
|
|
# converges the LDAP service account / first admin / OAuth client to the ./config
|
|
# values and writes the generated OAuth client creds into proxy-secrets.js),
|
|
# then starts the proxy and registers the SSO's + proxy's own hostnames as
|
|
# Host records in it (otherwise the proxy has no route for either). A single
|
|
# `./setup.sh` run is enough to bring an existing deployment fully up to date —
|
|
# no manual `git pull` needed first.
|
|
#
|
|
# What it does, in order:
|
|
# 0. Pull theta-suite's own latest commit (fast-forward only) and, if it
|
|
# moved, re-exec so the rest of this run uses the new script. Never
|
|
# blocks the run — skips silently with no upstream, warns and continues
|
|
# on any other pull failure (offline, local changes). Skip with
|
|
# SKIP_SELF_UPDATE=1.
|
|
# 1. Update the git submodules to the latest of their tracked remote branch
|
|
# (so each run builds the newest sso-manager-node + proxy). Skip with
|
|
# SKIP_SUBMODULE_UPDATE=1.
|
|
# 2. ensure_config: create ./config/sso-secrets.js + proxy-secrets.js if
|
|
# missing. On a fresh clone the domain/hosts are read from ./setup.env
|
|
# (the one place the domain is entered, as a plain DNS domain — the LDAP
|
|
# base DN is derived from it) and both files are generated with that
|
|
# domain filled in everywhere + random secrets, then the run proceeds to
|
|
# build (no edit-and-re-run step). On
|
|
# an existing deployment with .env/proxy.env, the secrets are migrated
|
|
# (preserved) into ./config. If ./config already exists it is left
|
|
# untouched (the operator owns it; setup.env is ignored).
|
|
# 3. backup_before_rebuild: snapshot ./config + LDAP (slapcat) + both Redis
|
|
# (BGSAVE + dump.rdb) to ./backups/<ts>/ before the rebuild. No-op on the
|
|
# very first run. Keeps the last BACKUP_KEEP (default 5).
|
|
# 4. docker compose up -d --build sso-manager; wait for /health.
|
|
# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js
|
|
# -> creates/updates the LDAP service account, first admin, OAuth client;
|
|
# writes the OAuth client creds into ./config/proxy-secrets.js; prints
|
|
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. Also seeds
|
|
# the SSO directory with the stack's own resources (site -> host ->
|
|
# SSO Manager + Proxy services, with the proxy's OAuth client linked
|
|
# under its service) so the Directory page is populated out of the
|
|
# box. Idempotent — existing slugs are operator-owned and left alone.
|
|
# 6. docker compose up -d --build proxy; wait for /health.
|
|
# 7. Register <SSO_HOST> and <PROXY_HOST> as Host records in the proxy (via
|
|
# `docker compose exec proxy node`, calling the proxy's Host model
|
|
# directly) so the proxy actually routes those hostnames somewhere —
|
|
# nothing else creates them. Idempotent; skips a host that already exists.
|
|
# 8. Print the first-admin login + the public URLs.
|
|
#
|
|
# Requires: git, docker + docker compose (v1 standalone or v2 plugin).
|
|
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")"
|
|
|
|
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
|
|
CONFIG_DIR=./config
|
|
BACKUP_DIR=./backups
|
|
BACKUP_KEEP="${BACKUP_KEEP:-5}"
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
info() { printf '\033[1;34m[setup]\033[0m %s\n' "$*"; }
|
|
warn() { printf '\033[1;33m[setup]\033[0m %s\n' "$*" >&2; }
|
|
error() { printf '\033[1;31m[setup]\033[0m %s\n' "$*" >&2; }
|
|
die() { error "$*"; exit 1; }
|
|
|
|
# ── Flags ──────────────────────────────────────────────────────────────────────
|
|
# --reset-openbao: wipe the OpenBao volume + bao-init.json and re-initialize a
|
|
# fresh store (no prod data to preserve). Use when OpenBao state is suspect
|
|
# (stale policies/tokens causing vault 403s). The Redis vault-token cache is
|
|
# flushed once sso-manager is back up (see the OpenBao bootstrap section).
|
|
RESET_OPENBAO=0
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--reset-openbao) RESET_OPENBAO=1 ;;
|
|
*) warn "unknown argument: $arg (ignored)" ;;
|
|
esac
|
|
done
|
|
|
|
# Escape a value for a single-quoted JS string: \ -> \\, ' -> \', then wrap in '...'.
|
|
js_str() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\'/\\\'}"
|
|
printf "'%s'" "$s"
|
|
}
|
|
|
|
# Random hex (openssl if available, else /dev/urandom).
|
|
rand_hex() {
|
|
if command -v openssl >/dev/null 2>&1; then
|
|
openssl rand -hex "${1:-32}"
|
|
else
|
|
head -c "$((${1:-32} / 2 + 1))" /dev/urandom | od -An -tx1 | tr -d ' \n' | cut -c1-$((2 * ${1:-32}))
|
|
fi
|
|
}
|
|
|
|
# Upsert KEY=VALUE into ./.env, which `docker compose` auto-loads for every
|
|
# future invocation in this directory. Used to persist the *_GIT_COMMIT build
|
|
# args (see SSO_GIT_COMMIT/PROXY_GIT_COMMIT/JUMP_GIT_COMMIT below) so that an
|
|
# ad-hoc `docker compose up --build <service>` run later, OUTSIDE this script,
|
|
# still resolves the right commit instead of silently baking "unknown" (the
|
|
# submodule .git pointer file can't be resolved from inside the build
|
|
# context, so the value must come from the host via this file or the export).
|
|
env_upsert() {
|
|
local key="$1" val="$2" file=./.env
|
|
touch "$file"
|
|
if grep -q "^${key}=" "$file" 2>/dev/null; then
|
|
sed -i "s|^${key}=.*|${key}=${val}|" "$file"
|
|
else
|
|
printf '%s=%s\n' "$key" "$val" >> "$file"
|
|
fi
|
|
}
|
|
|
|
# Read KEY= from ./.env (empty if absent)
|
|
env_get() {
|
|
local key="$1" file=./.env
|
|
[[ -f "$file" ]] || return 0
|
|
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- || true
|
|
}
|
|
|
|
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
|
|
if docker compose version >/dev/null 2>&1; then
|
|
COMPOSE=(docker compose)
|
|
elif command -v docker-compose >/dev/null 2>&1; then
|
|
COMPOSE=(docker-compose)
|
|
else
|
|
die "docker compose not found. Install Docker Compose (v2 plugin or v1 standalone)."
|
|
fi
|
|
|
|
# Is a named container running? (compose-independent check.)
|
|
running() { docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$1"; }
|
|
|
|
# Parse a KEY=VALUE file into the environment the way `docker compose` does:
|
|
# the value is everything after the FIRST '=' (so `ORG_NAME=My Org` works),
|
|
# outer wrapping quotes are stripped, blank/#/no-=/invalid-identifier lines
|
|
# are skipped. No shell expansion/eval is performed on values.
|
|
parse_kv_file() {
|
|
local file="$1" line key val qc
|
|
[[ -f "$file" ]] || return 0
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
[[ -z "$line" || "${line:0:1}" == '#' ]] && continue
|
|
[[ "$line" == *=* ]] || continue
|
|
key="${line%%=*}"
|
|
val="${line#*=}"
|
|
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
|
if [[ ${#val} -ge 2 ]]; then
|
|
qc="${val:0:1}"
|
|
if { [[ "$qc" == '"' || "$qc" == "'" ]] && [[ "${val: -1}" == "$qc" ]]; }; then
|
|
val="${val:1:${#val}-2}"
|
|
fi
|
|
fi
|
|
export "$key=$val"
|
|
done < "$file"
|
|
}
|
|
|
|
# ── 0. Self-update: pull theta-suite itself, then restart with the new version ──
|
|
# Step 1 below only refreshes the proxy/sso-manager-node submodules — it never
|
|
# updates setup.sh or this repo's own files. Pull the current branch's
|
|
# upstream (fast-forward only) before anything else, and if it moved, re-exec
|
|
# so the rest of THIS run uses the freshly-pulled script rather than the copy
|
|
# already read into memory. Never blocks the run: skips silently if this
|
|
# isn't a git checkout, is on a detached HEAD, or has no upstream configured;
|
|
# warns (but continues on the current checkout) if the pull fails for any
|
|
# other reason (offline, local changes that prevent a fast-forward). Skip
|
|
# entirely with SKIP_SELF_UPDATE=1.
|
|
if [[ "${SKIP_SELF_UPDATE:-0}" != "1" && "${THETA_SUITE_REEXECED:-0}" != "1" ]] \
|
|
&& command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
&& git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1
|
|
then
|
|
BEFORE_REV="$(git rev-parse HEAD)"
|
|
BEFORE_VER="$(git describe --tags "$BEFORE_REV" 2>/dev/null || echo "${BEFORE_REV:0:12}")"
|
|
if git pull --ff-only -q; then
|
|
AFTER_REV="$(git rev-parse HEAD)"
|
|
if [[ "$BEFORE_REV" != "$AFTER_REV" ]]; then
|
|
AFTER_VER="$(git describe --tags "$AFTER_REV" 2>/dev/null || echo "${AFTER_REV:0:12}")"
|
|
info "Updated theta-suite (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
|
|
THETA_SUITE_REEXECED=1 exec "$0" "$@"
|
|
fi
|
|
else
|
|
warn "Could not fast-forward theta-suite to the latest upstream (offline, or local changes) — continuing with the current checkout."
|
|
fi
|
|
fi
|
|
|
|
# ── Jump host hostname (always installed) ─────────────────────────────────────
|
|
# The SSH jump host is a core component — always built + started (no longer
|
|
# gated by CFG_JUMP_HOST_ENABLED). Read its optional hostname override from
|
|
# ./setup.env now (before the submodule loop and the compose steps) so the
|
|
# later steps can use it. The authoritative CFG_* for secrets are still
|
|
# resolved in ensure_config; this is only the hostname override.
|
|
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
|
export CFG_JUMP_HOST
|
|
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
|
export CFG_CREATE_ALL_HTTP
|
|
|
|
# ── Optional outbound HTTP(S) proxy for docker build + the running containers ─
|
|
# CFG_HTTP_PROXY / CFG_HTTPS_PROXY / CFG_NO_PROXY (from ./setup.env or the
|
|
# environment) — NOT the theta42 "proxy" app; this is an upstream HTTP proxy
|
|
# for reaching the internet (npm/apt during image builds, and SMTP/ACME/DNS
|
|
# provider calls at runtime), useful on isolated/offline/corporate-network
|
|
# test hosts. Off by default. docker-compose.yml passes these through as both
|
|
# build args (Docker also recognizes them as predefined build ARGs) and
|
|
# container environment on every service, so one setup.env entry covers the
|
|
# whole stack.
|
|
export CFG_HTTP_PROXY="${CFG_HTTP_PROXY:-}"
|
|
export CFG_HTTPS_PROXY="${CFG_HTTPS_PROXY:-${CFG_HTTP_PROXY:-}}"
|
|
export CFG_NO_PROXY="${CFG_NO_PROXY:-localhost,127.0.0.1,sso-manager,proxy,jump-host}"
|
|
if [[ -n "$CFG_HTTP_PROXY" ]]; then
|
|
info "Using HTTP proxy for docker build + containers: $CFG_HTTP_PROXY"
|
|
fi
|
|
|
|
# ── 1. Update submodules to their latest release tag, verify build contexts ───
|
|
# Submodules track release tags (vX.Y.Z), not the tip of master -- so
|
|
# "update" means "move to the newest tag", not "move to the newest commit".
|
|
# `git submodule update --init --recursive` (no --remote) only clones a
|
|
# missing submodule at its currently-pinned commit; it never advances it on
|
|
# its own, so the per-submodule tag resolution below is what actually moves
|
|
# proxy/sso-manager-node forward.
|
|
if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits."
|
|
fi
|
|
if ! git submodule update --init --recursive 2>&1; then
|
|
die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
|
|
fi
|
|
|
|
# jump-host is a core component — always tracked + built.
|
|
SUBMODULES=(sso-manager-node proxy jump-host)
|
|
info "Updating submodules to their latest release tag (${SUBMODULES[*]})..."
|
|
for sm in "${SUBMODULES[@]}"; do
|
|
[[ -d "$sm" ]] || continue
|
|
before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
|
|
# Prefer the exact tag the submodule is currently pinned to; fall back
|
|
# to a short commit hash if it's on an untagged commit (shouldn't
|
|
# normally happen -- this repo only ever pins tagged releases).
|
|
before_tag="$(git -C "$sm" describe --tags --exact-match "$before_rev" 2>/dev/null || echo "${before_rev:0:12}")"
|
|
|
|
if ! git -C "$sm" fetch --tags -q 2>&1; then
|
|
warn " ${sm}: could not fetch tags (offline?) — staying on ${before_tag}."
|
|
continue
|
|
fi
|
|
|
|
latest_tag="$(git -C "$sm" tag --list 'v*' --sort=-v:refname | head -n1)"
|
|
if [[ -z "$latest_tag" ]]; then
|
|
warn " ${sm}: no vX.Y.Z release tags found — staying on ${before_tag}."
|
|
continue
|
|
fi
|
|
|
|
if ! git -C "$sm" checkout -q "$latest_tag" 2>&1; then
|
|
warn " ${sm}: could not check out ${latest_tag} — staying on ${before_tag}."
|
|
continue
|
|
fi
|
|
|
|
after_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
|
|
if [[ "$before_rev" != "$after_rev" ]]; then
|
|
info " ${sm}: updated ${before_tag} -> ${latest_tag}"
|
|
else
|
|
info " ${sm}: already up to date (${latest_tag})"
|
|
fi
|
|
done
|
|
else
|
|
info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)."
|
|
fi
|
|
|
|
[[ -f sso-manager-node/Dockerfile.openldap ]] \
|
|
|| die "sso-manager-node/Dockerfile.openldap missing. Run: git submodule update --init --recursive"
|
|
[[ -f proxy/Dockerfile ]] \
|
|
|| die "proxy/Dockerfile missing. Run: git submodule update --init --recursive"
|
|
|
|
# ── 2. ensure_config ──────────────────────────────────────────────────────────
|
|
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar). Only used to
|
|
# read domain back out of a base DN set directly (advanced override, or an
|
|
# old setup.env / migrated .env) — the normal path is dn_from_domain below.
|
|
domain_from_dn() {
|
|
echo "$1" | sed 's/^dc=//; s/,dc=/./g'
|
|
}
|
|
|
|
# Derive an LDAP base DN from a DNS domain (foo.bar -> dc=foo,dc=bar). This is
|
|
# the normal path: operators enter a plain domain in setup.env (CFG_DOMAIN),
|
|
# and the base DN is built from it, however many labels it has (a DuckDNS
|
|
# domain like foo.duckdns.org becomes dc=foo,dc=duckdns,dc=org — LDAP doesn't
|
|
# care how many dc= components there are).
|
|
dn_from_domain() {
|
|
echo "dc=$1" | sed 's/\./,dc=/g'
|
|
}
|
|
|
|
# Read a value from the (operator-owned) ./config/sso-secrets.js -- the source of
|
|
# truth on re-runs, where the CFG_* first-run shell vars are not (re)derived
|
|
# (ensure_config returns early once sso-secrets.js exists). Reads `stack.<key>`.
|
|
# Prints empty on any failure. Usage: sso_secrets_get ldapBaseDn
|
|
sso_secrets_get() {
|
|
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c.stack&&c.stack[k]!=null?c.stack[k]:"")' \
|
|
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
|
}
|
|
|
|
# Read a top-level (non-stack) secret from sso-secrets.js, e.g. serviceAccountPass.
|
|
sso_secrets_get_top() {
|
|
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c[k]!=null?c[k]:"")' \
|
|
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
|
}
|
|
|
|
# Write ./config/sso-secrets.js from the CFG_* shell vars.
|
|
write_sso_secrets() {
|
|
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
|
|
[[ -n "$domain" ]] || domain="$(domain_from_dn "$dn")"
|
|
cat > "$CONFIG_DIR/sso-secrets.js" <<SSOEOF
|
|
'use strict';
|
|
// Generated by setup.sh. Edit freely; re-run ./setup.sh to apply.
|
|
// The SSO app reads this via @simpleworkjs/conf (CONF_SECRETS env var).
|
|
// The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by
|
|
// the orchestrator). Back this file up off-host — it holds all SSO secrets.
|
|
|
|
module.exports = {
|
|
name: $(js_str "$CFG_ORG"),
|
|
ldap: {
|
|
url: 'ldap://localhost:389',
|
|
bindDN: $(js_str "cn=admin,${dn}"),
|
|
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
|
|
userBase: $(js_str "ou=people,${dn}"),
|
|
groupBase: $(js_str "ou=groups,${dn}"),
|
|
ldapsHost: $(js_str "${CFG_LDAPS_HOST:-}"),
|
|
ldapsPort: 636,
|
|
},
|
|
smtp: {
|
|
host: $(js_str "${CFG_SMTP_HOST:-}"),
|
|
port: ${CFG_SMTP_PORT:-587},
|
|
secure: false,
|
|
user: $(js_str "${CFG_SMTP_USER:-}"),
|
|
pass: $(js_str "${CFG_SMTP_PASS:-}"),
|
|
from: $(js_str "${CFG_SMTP_FROM:-${CFG_ORG} <noreply@${domain}>}"),
|
|
},
|
|
oauth: {
|
|
issuer: $(js_str "https://${CFG_SSO_HOST}"),
|
|
jwtSecret: $(js_str "$CFG_JWT_SECRET"),
|
|
token_lifetime: { access_token: 3600, refresh_token: 2592000 },
|
|
},
|
|
// Without this, @simpleworkjs/orm falls back to './config/inventory.sqlite'
|
|
// relative to the app's /app cwd -- inside the container's ephemeral layer,
|
|
// not any mounted volume -- so every Resource/site/host/service/oauth row
|
|
// (the whole Directory Management page) would be silently wiped on every
|
|
// container recreate. /data is already a persisted volume (Redis lives
|
|
// there too), so this just co-locates the sqlite file with it.
|
|
orm: {
|
|
dialect: 'sqlite',
|
|
storage: '/data/inventory.sqlite',
|
|
},
|
|
|
|
// ── Orchestrator-only (ignored by the app) ───────────────────────────────
|
|
stack: {
|
|
ldapBaseDn: $(js_str "$dn"),
|
|
ldapDomain: $(js_str "$domain"),
|
|
siteName: $(js_str "${CFG_SITE_NAME:-local}"),
|
|
ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"),
|
|
ssoHost: $(js_str "$CFG_SSO_HOST"),
|
|
proxyHost: $(js_str "$CFG_PROXY_HOST"),
|
|
},
|
|
bootstrap: {
|
|
adminUid: $(js_str "$CFG_ADMIN_UID"),
|
|
adminPass: $(js_str "$CFG_ADMIN_PASS"),
|
|
adminEmail: $(js_str "$CFG_ADMIN_EMAIL"),
|
|
},
|
|
serviceAccountPass: $(js_str "$CFG_SVC_PASS"),
|
|
};
|
|
SSOEOF
|
|
}
|
|
|
|
# Write ./config/proxy-secrets.js from the CFG_* shell vars. clientId/clientSecret
|
|
# are placeholders; the bootstrap writes the generated values back into this file.
|
|
write_proxy_secrets() {
|
|
local dn="$CFG_BASE_DN"
|
|
cat > "$CONFIG_DIR/proxy-secrets.js" <<PROXYEOF
|
|
'use strict';
|
|
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (CONF_SECRETS
|
|
// env var). clientId/clientSecret are filled in by the bootstrap
|
|
// (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal
|
|
// serviceAccountPass in sso-secrets.js (the proxy binds as that account).
|
|
|
|
module.exports = {
|
|
oidc: {
|
|
enabled: true,
|
|
issuer: $(js_str "https://${CFG_SSO_HOST}"),
|
|
authorizationEndpoint: $(js_str "https://${CFG_SSO_HOST}/oauth/authorize"),
|
|
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
|
|
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
|
|
endSessionEndpoint: $(js_str "https://${CFG_SSO_HOST}/oauth/logout"),
|
|
clientId: $(js_str "$CFG_CLIENT_ID"),
|
|
clientSecret: $(js_str "$CFG_CLIENT_SECRET"),
|
|
redirectUri: $(js_str "https://${CFG_PROXY_HOST}/api/auth/oidc/callback"),
|
|
scopes: ['openid', 'profile', 'email', 'groups'],
|
|
groupsClaim: 'groups',
|
|
usernameClaim: 'preferred_username',
|
|
},
|
|
// Read-only SSO management API access, used to list directory groups for the
|
|
// per-host SSO allow-list autocomplete. apiToken is minted by the bootstrap.
|
|
sso: {
|
|
url: 'http://sso-manager:3001',
|
|
apiToken: '',
|
|
},
|
|
ldap: {
|
|
url: 'ldaps://sso-manager:636',
|
|
bindDN: $(js_str "cn=ldapclient,ou=people,${dn}"),
|
|
bindPassword: $(js_str "$CFG_SVC_PASS"),
|
|
searchBase: $(js_str "ou=people,${dn}"),
|
|
userFilter: '(objectClass=posixAccount)',
|
|
userNameAttribute: 'uid',
|
|
tlsOptions: { rejectUnauthorized: false },
|
|
},
|
|
auth: {
|
|
adminGroups: ['app_sso_admin'],
|
|
adminUsers: ['proxyadmin2'],
|
|
groupRoleMap: {},
|
|
// Initial password for the local anti-lockout admin (proxyadmin2) —
|
|
// only read by the proxy the first time that account is created;
|
|
// changing it here later has no effect on an already-created account.
|
|
localAdminPass: $(js_str "$CFG_PROXY_ADMIN_PASS"),
|
|
},
|
|
stack: {
|
|
ssoHost: $(js_str "$CFG_SSO_HOST"),
|
|
proxyHost: $(js_str "$CFG_PROXY_HOST"),
|
|
},
|
|
};
|
|
PROXYEOF
|
|
}
|
|
|
|
ensure_config() {
|
|
if [[ ! -f "$CONFIG_DIR/openbao.hcl" ]]; then
|
|
info "Generating $CONFIG_DIR/openbao.hcl ..."
|
|
mkdir -p "$CONFIG_DIR"
|
|
cat > "$CONFIG_DIR/openbao.hcl" <<BAOEOF
|
|
storage "file" {
|
|
path = "/vault/data"
|
|
}
|
|
listener "tcp" {
|
|
address = "0.0.0.0:8200"
|
|
tls_disable = 1
|
|
}
|
|
disable_mlock = true
|
|
ui = true
|
|
BAOEOF
|
|
chmod 644 "$CONFIG_DIR/openbao.hcl"
|
|
fi
|
|
|
|
if [[ -f "$CONFIG_DIR/sso-secrets.js" ]]; then
|
|
info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)."
|
|
return 0
|
|
fi
|
|
|
|
# First run: read the domain/hosts from ./setup.env — the ONE place the
|
|
# domain is entered (e.g. 718it.biz), as a plain DNS domain; the LDAP base
|
|
# DN is derived from it (dc=718it,dc=biz). Hostnames default to
|
|
# sso.<domain> / proxy.<domain>, also derived from it. setup.env is
|
|
# used ONLY on first run; once ./config/*.js exist they are operator-owned
|
|
# and setup.env is ignored. Falls back to legacy .env/proxy.env migration
|
|
# below for existing deployments.
|
|
if [[ -f ./setup.env ]]; then
|
|
info "Reading domain/hosts from ./setup.env ..."
|
|
parse_kv_file ./setup.env
|
|
fi
|
|
|
|
# Bind the CFG_* vars to empty where setup.env / the environment didn't set
|
|
# them, so the .env migration's `${LDAP_X:-$CFG_X}` defaults below don't trip
|
|
# `set -u`. Real values come from setup.env, the .env migration, or the
|
|
# derivation block further down (no example.com placeholders here).
|
|
CFG_BASE_DN="${CFG_BASE_DN:-}"
|
|
CFG_DOMAIN="${CFG_DOMAIN:-}"
|
|
CFG_SITE_NAME="${CFG_SITE_NAME:-}"
|
|
CFG_ORG="${CFG_ORG:-}"
|
|
CFG_SSO_HOST="${CFG_SSO_HOST:-}"
|
|
CFG_PROXY_HOST="${CFG_PROXY_HOST:-}"
|
|
CFG_ADMIN_UID="${CFG_ADMIN_UID:-}"
|
|
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}"
|
|
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
|
|
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
|
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
|
|
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
|
|
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}"
|
|
CFG_JWT_SECRET="${CFG_JWT_SECRET:-}"
|
|
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
|
|
CFG_SVC_PASS="${CFG_SVC_PASS:-}"
|
|
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
|
|
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
|
|
|
# ── One-time migration from .env / proxy.env (existing deployments) ──
|
|
# Preserve the operator's existing secrets so the running deployment keeps
|
|
# its LDAP directory, JWT, and OAuth client. After migration .env/proxy.env
|
|
# are dead weight — setup.sh prints a reminder to delete them.
|
|
local migrated=0
|
|
if [[ -f .env ]]; then
|
|
info "Migrating secrets from .env into $CONFIG_DIR/ ..."
|
|
parse_kv_file .env
|
|
CFG_BASE_DN="${LDAP_BASE_DN:-$CFG_BASE_DN}"
|
|
CFG_DOMAIN="${LDAP_DOMAIN:-$CFG_DOMAIN}"
|
|
CFG_ORG="${ORG_NAME:-$CFG_ORG}"
|
|
CFG_SSO_HOST="${SSO_HOST:-$CFG_SSO_HOST}"
|
|
CFG_PROXY_HOST="${PROXY_HOST:-$CFG_PROXY_HOST}"
|
|
CFG_ADMIN_UID="${BOOTSTRAP_ADMIN_UID:-$CFG_ADMIN_UID}"
|
|
CFG_ADMIN_EMAIL="${BOOTSTRAP_ADMIN_EMAIL:-$CFG_ADMIN_EMAIL}"
|
|
CFG_LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$CFG_LDAP_ADMIN_PASS}"
|
|
CFG_JWT_SECRET="${JWT_SECRET:-$CFG_JWT_SECRET}"
|
|
CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}"
|
|
CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}"
|
|
CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}"
|
|
# .env has no legacy LDAPS_HOST key; this stays as set in setup.env/env.
|
|
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
|
CFG_SMTP_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}"
|
|
CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}"
|
|
CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}"
|
|
CFG_SMTP_PASS="${SMTP_PASS:-${CFG_SMTP_PASS:-}}"
|
|
CFG_SMTP_FROM="${SMTP_FROM:-${CFG_SMTP_FROM:-}}"
|
|
migrated=1
|
|
fi
|
|
if [[ -f proxy.env ]]; then
|
|
info "Migrating proxy config from proxy.env into $CONFIG_DIR/ ..."
|
|
# proxy.env uses app_* keys; pull the OAuth client creds out directly.
|
|
CFG_CLIENT_ID="$(grep -m1 '^app_oidc__clientId=' proxy.env 2>/dev/null | cut -d= -f2- || true)"
|
|
CFG_CLIENT_SECRET="$(grep -m1 '^app_oidc__clientSecret=' proxy.env 2>/dev/null | cut -d= -f2- || true)"
|
|
# LDAP_BIND_PASSWORD in proxy.env == the service account pass.
|
|
local pbp; pbp="$(grep -m1 '^app_ldap__bindPassword=' proxy.env 2>/dev/null | cut -d= -f2- || true)"
|
|
[[ -n "$pbp" ]] && CFG_SVC_PASS="$pbp"
|
|
migrated=1
|
|
fi
|
|
|
|
# Derive everything from the domain — the one value operators enter. No
|
|
# example.com defaults: a blank domain means first-run setup hasn't been
|
|
# done yet. CFG_BASE_DN can still be set directly (setup.env or a migrated
|
|
# .env) to override the derived DN or to read the domain back out of an
|
|
# old-style DN-first setup.env; if not, it's built from CFG_DOMAIN.
|
|
CFG_DOMAIN="${CFG_DOMAIN:-$([[ -n "$CFG_BASE_DN" ]] && domain_from_dn "$CFG_BASE_DN" || true)}"
|
|
[[ -n "$CFG_DOMAIN" ]] \
|
|
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
|
|
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
|
|
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
|
|
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
|
|
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
|
|
CFG_ORG="${CFG_ORG:-SSO Manager}"
|
|
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
|
|
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
|
|
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
|
|
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
|
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
|
|
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
|
|
# Random secrets (generated fresh unless sourced/migrated above). These do
|
|
# NOT belong in setup.env — they're written into ./config/*.js only.
|
|
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-$(rand_hex 16)}"
|
|
CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}"
|
|
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}"
|
|
CFG_SVC_PASS="${CFG_SVC_PASS:-$(rand_hex 16)}"
|
|
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-$(rand_hex 16)}"
|
|
|
|
mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
|
|
write_sso_secrets
|
|
write_proxy_secrets
|
|
|
|
chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js"
|
|
|
|
if [[ "$migrated" == "1" ]]; then
|
|
info "Migrated secrets into $CONFIG_DIR/ (existing LDAP dir / JWT / OAuth client preserved)."
|
|
info "You may now delete .env and proxy.env — they are no longer used."
|
|
else
|
|
info "Generated $CONFIG_DIR/sso-secrets.js + proxy-secrets.js from ./setup.env (domain=$CFG_DOMAIN)."
|
|
info "Edit $CONFIG_DIR/*.js to change secrets later; re-run ./setup.sh to rebuild."
|
|
fi
|
|
}
|
|
|
|
ensure_config
|
|
|
|
# ── 3. backup_before_rebuild ──────────────────────────────────────────────────
|
|
# Snapshot ./config + LDAP (slapcat) + both Redis (BGSAVE + dump.rdb) before the
|
|
# rebuild. No-op on the very first run (nothing running, no config to lose yet).
|
|
backup_before_rebuild() {
|
|
# If a command trips `set -e` and aborts the snapshot, name the offending
|
|
# command instead of dying silently after "Snapshotting state to ..." (the
|
|
# ERR trap fires for the same failures set -e would exit on, with the same
|
|
# if/&&/|| exemptions, and is scoped to this function).
|
|
trap 'warn " snapshot aborted by command: $BASH_COMMAND"' ERR
|
|
local any_running=0
|
|
running sso-manager && any_running=1
|
|
running proxy && any_running=1
|
|
if [[ "$any_running" == "0" && ! -d "$CONFIG_DIR" ]]; then
|
|
info "First run — nothing to back up yet."
|
|
return 0
|
|
fi
|
|
|
|
# A timestamp suffix. `date` is fine here (setup.sh runs on the host).
|
|
local ts; ts="$(date +%Y%m%d-%H%M%S)"
|
|
local dir="$BACKUP_DIR/$ts"
|
|
mkdir -p "$dir" && chmod 700 "$dir"
|
|
info "Snapshotting state to $dir/ before rebuild..."
|
|
|
|
# Config (the secrets source — the most important thing to back up).
|
|
if [[ -d "$CONFIG_DIR" ]]; then
|
|
if cp -a "$CONFIG_DIR" "$dir/config" 2>/dev/null; then
|
|
info " config -> config/"
|
|
else
|
|
warn " could not copy $CONFIG_DIR/"
|
|
fi
|
|
fi
|
|
|
|
# LDAP — slapcat the live directory while slapd is running. Read the base
|
|
# DN from the host-side config first (works whether or not the running
|
|
# container has /config mounted — e.g. a container from before the ./config
|
|
# bind-mount was added), then fall back to reading it inside the container.
|
|
# Use `docker exec <name>` (not `docker-compose exec`) so the snapshot works
|
|
# no matter which compose project brought the container up — the unified
|
|
# theta-suite stack (project "theta-suite") and the standalone submodule stack
|
|
# (project "sso-manager-node") both name it "sso-manager". `docker-compose
|
|
# exec` from the superproject otherwise exits 1 silently (wrong project) and
|
|
# the snapshot silently no-ops.
|
|
if running sso-manager; then
|
|
info " LDAP: snapshotting..."
|
|
local basedn=""
|
|
if [[ -f "$CONFIG_DIR/sso-secrets.js" ]] && command -v node >/dev/null 2>&1; then
|
|
# `timeout 5` guards against a malformed secrets.js that blocks at
|
|
# require() time — without it a bad config would stall the whole
|
|
# rebuild at this line with no further output.
|
|
basedn="$(timeout 5 node -e 'console.log((require("'"$PWD/$CONFIG_DIR"'/sso-secrets.js").stack||{}).ldapBaseDn||"")' 2>/dev/null || true)"
|
|
fi
|
|
if [[ -z "$basedn" ]]; then
|
|
basedn="$(docker exec sso-manager node -e \
|
|
'console.log((require("/config/sso-secrets.js").stack||{}).ldapBaseDn||"")' 2>/dev/null || true)"
|
|
fi
|
|
if [[ -n "$basedn" ]]; then
|
|
if timeout 20 docker exec sso-manager slapcat -f /etc/openldap/slapd.conf \
|
|
-b "$basedn" > "$dir/ldap.ldif" 2>/dev/null; then
|
|
info " LDAP -> ldap.ldif ($basedn)"
|
|
else
|
|
warn " slapcat failed (LDAP not ready?) — LDAP not snapshotted"
|
|
fi
|
|
else
|
|
warn " could not read ldapBaseDn from sso-secrets.js — LDAP not snapshotted"
|
|
fi
|
|
else
|
|
info " LDAP: sso-manager not running — skipped"
|
|
fi
|
|
|
|
# Redis — snapshot each running service. Capture LASTSAVE *before* issuing
|
|
# BGSAVE: a small dataset finishes in well under a second, so capturing it
|
|
# afterward races the save and the poll never sees a fresh value (the bug
|
|
# behind "BGSAVE did not finish in 30s"). BGSAVE is non-blocking but can
|
|
# fork-fail when the host has vm.overcommit_memory=0; fall back to a
|
|
# synchronous SAVE (blocks Redis briefly, but can't fork-fail — and we're
|
|
# about to tear the containers down for a rebuild anyway). Then copy the RDB
|
|
# from Redis's own `dir`/`dbfilename` (not a hardcoded /data/dump.rdb — the
|
|
# standalone SSO stack keeps it at /app/dump.rdb) via `docker cp` by name, so
|
|
# it works regardless of which compose project owns the container.
|
|
local svc before ok rdir rfile rpath
|
|
for svc in sso-manager proxy; do
|
|
if ! running "$svc"; then
|
|
info " Redis ($svc): not running — skipped"
|
|
continue
|
|
fi
|
|
info " Redis ($svc): snapshotting..."
|
|
before="$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)"
|
|
docker exec "$svc" redis-cli BGSAVE >/dev/null 2>&1 || true
|
|
ok=0
|
|
for i in $(seq 1 10); do
|
|
if [[ "$(docker exec "$svc" redis-cli LASTSAVE 2>/dev/null | tr -dc '0-9' || echo 0)" -gt "$before" ]]; then
|
|
ok=1; break
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [[ "$ok" != "1" ]]; then
|
|
# BGSAVE didn't advance LASTSAVE in time (fork failure / save already
|
|
# in progress) — synchronous SAVE. Reply must be "OK".
|
|
[[ "$(docker exec "$svc" redis-cli SAVE 2>/dev/null | tr -d '\r\n')" == "OK" ]] && ok=1
|
|
fi
|
|
# Redis writes dump.rdb to `dir`/`dbfilename`; ask it where that is so the
|
|
# copy works across the unified (/data) and standalone (/app) layouts.
|
|
rdir="$(docker exec "$svc" redis-cli CONFIG GET dir 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
|
|
rfile="$(docker exec "$svc" redis-cli CONFIG GET dbfilename 2>/dev/null | sed -n '2p' | tr -d '\r\n' || true)"
|
|
rpath="${rdir:+$rdir/}${rfile:-dump.rdb}"
|
|
if [[ "$ok" == "1" ]] && docker cp "$svc:$rpath" "$dir/$svc.rdb" >/dev/null 2>&1; then
|
|
info " Redis ($svc) -> $svc.rdb"
|
|
else
|
|
warn " $svc: snapshot failed — Redis not snapshotted"
|
|
fi
|
|
done
|
|
|
|
# Retention: keep the newest BACKUP_KEEP (min 1). Use `[[ ]]` (not `(( ))`)
|
|
# for the min-1 clamp: `(( keep < 1 ))` returns exit 1 when false, which is a
|
|
# classic set -e landmine — `[[ ]]` is exempt as the left operand of `&&`.
|
|
local keep="${BACKUP_KEEP:-5}"
|
|
[[ "$keep" -lt 1 ]] && keep=1
|
|
info " pruning old backups (keep=$keep)..."
|
|
local removed=0
|
|
while read -r old; do
|
|
[[ -n "$old" ]] || continue
|
|
# Only prune real backup dirs — skip symlinks (a stray symlink could
|
|
# point rm at an arbitrary tree) and non-dir entries.
|
|
[[ -d "$BACKUP_DIR/$old" && ! -L "$BACKUP_DIR/$old" ]] || continue
|
|
rm -rf "${BACKUP_DIR:?}/$old" || true
|
|
removed=$((removed + 1))
|
|
done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | sort -r | tail -n +$((keep + 1)))
|
|
[[ "$removed" -gt 0 ]] && info " pruned $removed old backup(s) (keeping $keep)."
|
|
info " snapshot complete."
|
|
}
|
|
backup_before_rebuild
|
|
|
|
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
|
|
# Full reset (--reset-openbao): stop/remove openbao, drop the data volume, and
|
|
# delete the init/keys file (incl. any backup copy that setup.sh would otherwise
|
|
# restore). The normal bootstrap below then initializes a brand-new store, so no
|
|
# stale policy content or token survives.
|
|
if [[ "$RESET_OPENBAO" == "1" ]]; then
|
|
info "── Full OpenBao reset requested (--reset-openbao) ──"
|
|
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
|
|
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
|
|
docker volume ls -q 2>/dev/null | grep '^openbao' | xargs -r docker volume rm >/dev/null 2>&1 || true
|
|
rm -f "$CONFIG_DIR/bao-init.json"
|
|
rm -f ./backups/bao-init.json ./backups/*/bao-init.json 2>/dev/null || true
|
|
info " openbao volume + bao-init.json cleared; will re-initialize fresh."
|
|
fi
|
|
|
|
info "Starting openbao..."
|
|
"${COMPOSE[@]}" run --rm --user root openbao chown -R 100:1000 /vault/data
|
|
"${COMPOSE[@]}" up -d openbao
|
|
info "Waiting for openbao to be reachable..."
|
|
for i in $(seq 1 30); do
|
|
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then
|
|
info "openbao is reachable."; break
|
|
fi
|
|
if (( i == 30 )); then die "openbao did not become reachable in 60s. Check: ${COMPOSE[*]} logs openbao"; fi
|
|
sleep 2
|
|
done
|
|
|
|
# If config/bao-init.json is missing, search backups for a saved copy
|
|
if [[ ! -f "$CONFIG_DIR/bao-init.json" ]]; then
|
|
latest_backup_init=$(find ./backups -name "bao-init.json" 2>/dev/null | sort -r | head -n1 || true)
|
|
if [[ -n "$latest_backup_init" && -f "$latest_backup_init" ]]; then
|
|
info "Restoring $CONFIG_DIR/bao-init.json from backup ($latest_backup_init)..."
|
|
cp "$latest_backup_init" "$CONFIG_DIR/bao-init.json"
|
|
chmod 600 "$CONFIG_DIR/bao-init.json"
|
|
fi
|
|
fi
|
|
|
|
if ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true'; then
|
|
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
|
if ! echo "$status_json" | grep -q '"initialized": true'; then
|
|
info "Initializing openbao for the first time..."
|
|
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
|
|
chmod 600 "$CONFIG_DIR/bao-init.json"
|
|
info "Openbao initialized. Keys saved to $CONFIG_DIR/bao-init.json"
|
|
fi
|
|
fi
|
|
|
|
status_json=$(docker exec openbao bao status -format=json 2>/dev/null || true)
|
|
if echo "$status_json" | grep -q '"sealed": true'; then
|
|
UNSEAL_KEY=""
|
|
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
|
|
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | tail -n1 | cut -d'"' -f2 || true)
|
|
fi
|
|
if [[ -z "$UNSEAL_KEY" ]]; then
|
|
UNSEAL_KEY="$(env_get VAULT_UNSEAL_KEY)"
|
|
fi
|
|
|
|
if [[ -n "$UNSEAL_KEY" ]]; then
|
|
info "Unsealing openbao..."
|
|
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
|
|
else
|
|
warn "OpenBao is sealed with an unrecoverable key. Resetting OpenBao volume and re-initializing..."
|
|
"${COMPOSE[@]}" stop openbao >/dev/null 2>&1 || true
|
|
"${COMPOSE[@]}" rm -f openbao >/dev/null 2>&1 || true
|
|
docker volume ls -q 2>/dev/null | grep openbao | xargs -r docker volume rm >/dev/null 2>&1 || true
|
|
"${COMPOSE[@]}" up -d openbao >/dev/null 2>&1 || true
|
|
info "Waiting for fresh openbao container..."
|
|
for i in $(seq 1 30); do
|
|
if docker exec openbao bao status >/dev/null 2>&1 || [[ $? -eq 2 ]]; then break; fi
|
|
sleep 2
|
|
done
|
|
info "Initializing fresh openbao..."
|
|
docker exec openbao bao operator init -key-shares=1 -key-threshold=1 -format=json > "$CONFIG_DIR/bao-init.json"
|
|
chmod 600 "$CONFIG_DIR/bao-init.json"
|
|
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
|
|
info "Unsealing fresh openbao..."
|
|
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
|
|
fi
|
|
fi
|
|
|
|
export VAULT_TOKEN
|
|
if [[ -f "$CONFIG_DIR/bao-init.json" ]]; then
|
|
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" 2>/dev/null | cut -d'"' -f4 || true)
|
|
fi
|
|
if [[ -z "$VAULT_TOKEN" ]]; then
|
|
VAULT_TOKEN="$(env_get VAULT_TOKEN)"
|
|
fi
|
|
|
|
if [[ -z "$VAULT_TOKEN" ]]; then
|
|
die "Could not determine OpenBao VAULT_TOKEN from $CONFIG_DIR/bao-init.json or .env."
|
|
fi
|
|
|
|
# UNSEAL_KEY is only set when OpenBao needed unsealing this run; on a re-run of
|
|
# an already-unsealed store it is unset, so guard with ${UNSEAL_KEY:-} (set -u).
|
|
if [[ -n "${UNSEAL_KEY:-}" ]]; then
|
|
env_upsert VAULT_UNSEAL_KEY "$UNSEAL_KEY"
|
|
fi
|
|
env_upsert VAULT_TOKEN "$VAULT_TOKEN"
|
|
|
|
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets list -format=json 2>/dev/null | grep -q '"secret/":'; then
|
|
info "Enabling kv-v2 secrets engine at secret/..."
|
|
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao secrets enable -path=secret kv-v2 >/dev/null
|
|
fi
|
|
|
|
# ── 3c. OpenBao policies, token role, per-app tokens ─────────────────────────
|
|
# Each app gets a least-privilege scoped token (a policy over only its own
|
|
# secret/<app>/conf). sso additionally gets the `sso-broker` policy so it can
|
|
# mint per-user (user-<uid>) and per-app (app-<name>) tokens at runtime through
|
|
# the sso-broker token role. The root VAULT_TOKEN stays in .env for
|
|
# setup/maintenance ONLY and is never passed to a service container. Everything
|
|
# here is idempotent — re-running setup.sh keeps existing policies/tokens.
|
|
|
|
# Run a `bao` command inside the openbao container as root.
|
|
bao_run() { docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao "$@"; }
|
|
|
|
# Write an ACL policy from stdin HCL only if it does not already exist.
|
|
ensure_policy() {
|
|
local name="$1"
|
|
# Always (re)write: `bao policy write` is an idempotent overwrite, so this
|
|
# applies policy edits on a re-run instead of stranding the old HCL
|
|
# forever ("already exists — keeping" silently dropped upgrades — e.g.
|
|
# the secret/metadata mount-root list grant added for the /vault fix).
|
|
info " writing policy ${name}..."
|
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
|
|
}
|
|
|
|
|
|
# Mint a PERIODIC service token (theta-svc role: orphan, renewable, 768h
|
|
# period) for `policy` and persist it to .env as `key`. Periodic tokens have no
|
|
# max-TTL death date — each renewal resets the clock — unlike the plain orphan
|
|
# tokens minted before this (creation_ttl 768h, dead ~32 days after mint no
|
|
# matter what). The bao-renewer sidecar renews them every 12h while the stack
|
|
# runs, and every setup.sh re-run renews here too. A valid-but-non-periodic
|
|
# token from an older setup.sh is revoked and re-minted as periodic.
|
|
ensure_token() {
|
|
local key="$1" policy="$2" existing tok lookup
|
|
existing="$(env_get "$key")"
|
|
if [[ -n "$existing" ]]; then
|
|
lookup="$(docker exec -e BAO_TOKEN="$existing" openbao bao token lookup -format=json 2>/dev/null || true)"
|
|
if [[ -n "$lookup" ]]; then
|
|
# Periodic = minted through the theta-svc role. (OpenBao token lookup
|
|
# does not expose a `period` field — the role is the reliable marker;
|
|
# renewal behavior confirms the 768h period resets past the original
|
|
# creation TTL.)
|
|
if echo "$lookup" | grep -q '"role": *"theta-svc"'; then
|
|
info " ${key} already minted + periodic (theta-svc) — renewing to reset its clock."
|
|
docker exec -e BAO_TOKEN="$existing" openbao bao token renew >/dev/null 2>&1 || true
|
|
return 0
|
|
fi
|
|
info " ${key} is valid but NOT periodic (pre-theta-svc mint; dies at its max TTL) — revoking + re-minting."
|
|
bao_run token revoke "$existing" >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
info " minting ${key} (policy=${policy}, role=theta-svc, periodic 768h)..."
|
|
tok="$(bao_run token create -role=theta-svc -policy="$policy" -field=token)" \
|
|
|| die "failed to mint ${key} (policy=${policy})"
|
|
env_upsert "$key" "$tok"
|
|
}
|
|
|
|
# Seed secret/<vault_path> from a /config/*.js module on first run only
|
|
# (skipped if the path already exists). Fail-soft: a seed failure leaves the
|
|
# app's file-mounted config as the fallback — boot is not blocked.
|
|
seed_app_conf() {
|
|
local vault_path="$1" mod="$2"
|
|
if bao_run kv get "secret/${vault_path}" >/dev/null 2>&1; then
|
|
info " secret/${vault_path} already seeded — keeping."
|
|
return 0
|
|
fi
|
|
info "Seeding secret/${vault_path} from ${mod}..."
|
|
docker exec sso-manager node -e "console.log(JSON.stringify(require('${mod}')))" 2>/dev/null \
|
|
| docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put "secret/${vault_path}" - >/dev/null \
|
|
|| warn " could not seed secret/${vault_path} (continuing — app will use its file fallback)"
|
|
}
|
|
|
|
info "Configuring OpenBao policies..."
|
|
# sso-broker — sso's authority to read/write its own conf, mint per-user and
|
|
# per-app tokens (auth/token/create/sso-broker), and create the matching
|
|
# user-<uid> / app-<name> / sso-admin policies. secret/plugins/* holds per-instance
|
|
# plugin secrets managed by the SSO plugin system (configurable plugin copies,
|
|
# loaded/unloaded at runtime — see sso-manager-node docs/plugins.md).
|
|
ensure_policy sso-broker <<'HCL'
|
|
path "secret/data/sso-manager/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/sso-manager/conf" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/proxy/conf" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/proxy/conf" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/users/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/users/*" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
|
|
# The Ed25519 key the SSO signs high-risk theta-agent commands with. It must
|
|
# persist across restarts: agents pin the matching public key in agent.yml, so
|
|
# a key that changes on every boot makes signature verification meaningless.
|
|
path "secret/data/agent/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/agent/*" { capabilities = ["list", "read", "delete"] }
|
|
path "auth/token/create/sso-broker" { capabilities = ["update"] }
|
|
path "auth/token/create/sso-app" { capabilities = ["update"] }
|
|
path "auth/token/renew-accessor" { capabilities = ["update"] }
|
|
path "auth/token/revoke-accessor" { capabilities = ["update"] }
|
|
path "auth/token/lookup-accessor" { capabilities = ["update"] }
|
|
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
HCL
|
|
# sso-admin — admin users in the vault UI: read/write/list everything under secret/.
|
|
# The bare `secret/metadata` grant lets an admin LIST the KV mount root (the
|
|
# top-level dirs); `secret/metadata/*` covers nested paths but not the root
|
|
# itself, so without it the /vault secrets list 403s.
|
|
ensure_policy sso-admin <<'HCL'
|
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }
|
|
HCL
|
|
# proxy / jump-host — read only their own boot conf.
|
|
ensure_policy proxy <<'HCL'
|
|
path "secret/data/proxy/conf" { capabilities = ["read"] }
|
|
path "secret/data/proxy/dns-providers/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/proxy/dns-providers/*" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/metadata/proxy/conf" { capabilities = ["read", "list"] }
|
|
HCL
|
|
ensure_policy jump-host <<'HCL'
|
|
path "secret/data/jump-host/conf" { capabilities = ["read"] }
|
|
path "secret/metadata/jump-host/conf" { capabilities = ["read", "list"] }
|
|
HCL
|
|
|
|
# sso-broker token role: lets sso mint user-*/app-*/sso-admin tokens. Orphan,
|
|
# renewable, 24h period. Wildcards need allowed_policies_glob — allowed_policies
|
|
# is exact-match only.
|
|
info "Configuring sso-broker token role..."
|
|
if ! bao_run read auth/token/roles/sso-broker >/dev/null 2>&1; then
|
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-broker - <<'JSON' >/dev/null
|
|
{"allowed_policies":["sso-admin"],"allowed_policies_glob":["user-*","app-*"],"orphan":true,"renewable":true,"token_period":"24h"}
|
|
JSON
|
|
else
|
|
info " token role sso-broker already exists — keeping."
|
|
fi
|
|
|
|
# sso-app token role: external-app tokens minted from the sso vault UI. Periodic
|
|
# 768h (NOT the broker's 24h) — an app token is a long-lived credential; with a
|
|
# 24h period any downstream app that didn't renew daily silently died. A 768h
|
|
# period keeps it alive as long as the app renews (or is re-minted) at least
|
|
# monthly: `bao token renew-self` / POST /v1/auth/token/renew-self.
|
|
info "Configuring sso-app token role..."
|
|
if ! bao_run read auth/token/roles/sso-app >/dev/null 2>&1; then
|
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/sso-app - <<'JSON' >/dev/null
|
|
{"allowed_policies_glob":["app-*"],"orphan":true,"renewable":true,"token_period":"768h"}
|
|
JSON
|
|
else
|
|
info " token role sso-app already exists — keeping."
|
|
fi
|
|
|
|
# theta-svc token role: the services' own tokens (SSO/PROXY/JUMP_VAULT_TOKEN).
|
|
# Periodic 768h so they can be renewed forever (the bao-renewer sidecar renews
|
|
# every 12h; each setup.sh re-run renews too). allowed_policies is exact-match:
|
|
# exactly the three service policies, nothing else.
|
|
info "Configuring theta-svc token role..."
|
|
if ! bao_run read auth/token/roles/theta-svc >/dev/null 2>&1; then
|
|
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao write auth/token/roles/theta-svc - <<'JSON' >/dev/null
|
|
{"allowed_policies":["sso-broker","proxy","jump-host"],"orphan":true,"renewable":true,"token_period":"768h"}
|
|
JSON
|
|
else
|
|
info " token role theta-svc already exists — keeping."
|
|
fi
|
|
|
|
info "Minting per-app OpenBao tokens (stored in .env, passed to containers as VAULT_TOKEN)..."
|
|
ensure_token SSO_VAULT_TOKEN sso-broker
|
|
ensure_token PROXY_VAULT_TOKEN proxy
|
|
ensure_token JUMP_VAULT_TOKEN jump-host
|
|
|
|
info "OpenBao secrets configured:"
|
|
info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app created lazily by sso)"
|
|
info " token roles: sso-broker (user-*/app-*/sso-admin, 24h period), sso-app (app-*, 768h period), theta-svc (service tokens, 768h period)"
|
|
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env (periodic; renewed by bao-renewer)"
|
|
|
|
# bao-renewer: renews the three periodic service tokens every 12h so they never
|
|
# hit their period boundary while the stack is running. Recreated (not just
|
|
# started) so it always picks up freshly re-minted tokens from .env.
|
|
info "Starting bao-renewer (service-token renewal sidecar)..."
|
|
"${COMPOSE[@]}" up -d --force-recreate bao-renewer
|
|
|
|
# ── 4. Start SSO Manager, wait for health ─────────────────────────────────────
|
|
# SSO_GIT_COMMIT: sso-manager-node is a git submodule here, so its .git is a
|
|
# pointer file (not a real repo) -- the image can't resolve its own commit
|
|
# hash from inside the Docker build context. Resolve it on the host (where
|
|
# the submodule DOES resolve correctly) and pass it in as a build arg; see
|
|
# docker-compose.yml and sso-manager-node's Dockerfile.openldap.
|
|
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
export SSO_GIT_COMMIT
|
|
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
|
|
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
|
|
"${COMPOSE[@]}" up -d --build sso-manager
|
|
|
|
info "Waiting for sso-manager to be healthy..."
|
|
for i in $(seq 1 60); do
|
|
status=$("${COMPOSE[@]}" ps -o json sso-manager 2>/dev/null \
|
|
| grep -o '"Health":"healthy"' || true)
|
|
if [[ -n "$status" ]]; then info "sso-manager is healthy."; break; fi
|
|
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
|
|
info "sso-manager is healthy (probed /health)."; break
|
|
fi
|
|
if (( i == 60 )); then die "sso-manager did not become healthy in 60s. Check: ${COMPOSE[*]} logs sso-manager"; fi
|
|
sleep 2
|
|
done
|
|
|
|
# After a full OpenBao reset, the Redis-cached per-user/admin vault tokens (in
|
|
# the persisted sso-data volume) reference the old, now-wiped store — drop them
|
|
# so the broker re-mints fresh tokens against the new instance. Belt-and-
|
|
# suspenders: the broker also always reconciles policy content before serving a
|
|
# token, but a token minted by the previous OpenBao instance is simply invalid
|
|
# there, so a cache flush is required after a reset.
|
|
if [[ "$RESET_OPENBAO" == "1" ]]; then
|
|
info " clearing cached vault tokens (old OpenBao instance)..."
|
|
docker exec sso-manager sh -c "redis-cli EVAL \"for _,k in ipairs(redis.call('keys','vault_token:*')) do redis.call('del',k) end\" 0" \
|
|
>/dev/null 2>&1 || warn " could not flush Redis vault-token cache (will re-mint on next access)"
|
|
fi
|
|
|
|
info "Seeding app configs into OpenBao (idempotent)..."
|
|
# sso-manager/conf holds the operator-set LDAP/SMTP/jwtSecret values — sso has
|
|
# no bootstrap-generated creds, so the file is the complete source of truth.
|
|
seed_app_conf sso-manager/conf /config/sso-secrets.js
|
|
# proxy/conf is seeded from the operator file (placeholder OAuth creds); the
|
|
# bootstrap (step 5) then writes the real generated OAuth client creds into
|
|
# OpenBao over this. proxy boots at step 6, after bootstrap, so it sees the
|
|
# real values.
|
|
seed_app_conf proxy/conf /config/proxy-secrets.js
|
|
|
|
# Read the summary values (hosts, admin, base DN) back from ./config via the
|
|
# running container's node — works whether ./config was generated or pre-existing.
|
|
read_config_kv() {
|
|
"${COMPOSE[@]}" exec -T sso-manager node -e '
|
|
const c = require("/config/sso-secrets.js");
|
|
let p = {};
|
|
try { p = require("/config/proxy-secrets.js"); } catch (_) {}
|
|
const o = {
|
|
SSO_HOST: (c.stack && c.stack.ssoHost) || "",
|
|
PROXY_HOST: (c.stack && c.stack.proxyHost) || "",
|
|
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
|
|
ORG_NAME: c.name || "",
|
|
ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "",
|
|
};
|
|
for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k]));
|
|
' 2>/dev/null
|
|
}
|
|
CFG_OUT="$(read_config_kv || true)"
|
|
cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
|
SSO_HOST="$(cfgval SSO_HOST)"
|
|
PROXY_HOST="$(cfgval PROXY_HOST)"
|
|
ADMIN_UID="$(cfgval ADMIN_UID)"
|
|
|
|
info "Stack config:"
|
|
info " SSO host: https://${SSO_HOST}"
|
|
info " Proxy host: https://${PROXY_HOST}"
|
|
info " Admin uid: ${ADMIN_UID}"
|
|
|
|
# ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ──
|
|
# The bootstrap reads its inputs from /config/*.js (not env) and writes the
|
|
# generated OAuth client creds back into /config/proxy-secrets.js AND into
|
|
# OpenBao (secret/proxy/conf, secret/jump-host/conf) so the proxy + jump host
|
|
# load them from OpenBao at boot. The root VAULT_TOKEN is passed on this one
|
|
# exec so bootstrap can write those paths; it is never handed to a service
|
|
# container.
|
|
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
|
|
# Host facts for the directory seed — collected HERE (on the host; inside the
|
|
# container hostname/uname describe the container, not the machine). Same
|
|
# collection as ldap-client/index.sh so stack hosts and ldap-client-joined
|
|
# hosts carry identical metadata. All best-effort: a missing tool just leaves
|
|
# the field blank.
|
|
STACK_HOST_NAME="$(hostname 2>/dev/null || true)"
|
|
STACK_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
|
_iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)"
|
|
STACK_HOST_MAC=""
|
|
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
|
|
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
|
|
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
|
|
# The compose project name the stack runs under (defaults to the directory
|
|
# name). The bootstrap hands it to the Docker discovery plugin so the stack's
|
|
# own containers are recognised as ours rather than discovered as strangers.
|
|
STACK_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-' | sed 's/-*$//')}"
|
|
|
|
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
|
|
-e COMPOSE_PROJECT_NAME="$STACK_COMPOSE_PROJECT" \
|
|
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
|
|
-e STACK_HOST_IP="$STACK_HOST_IP" \
|
|
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
|
|
-e STACK_HOST_OS="$STACK_HOST_OS" \
|
|
-e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \
|
|
-e CFG_JUMP_HOST="${CFG_JUMP_HOST:-}" \
|
|
-e VAULT_ADDR=http://openbao:8200 \
|
|
-e VAULT_TOKEN="$VAULT_TOKEN" \
|
|
sso-manager node /bootstrap/bootstrap.js) \
|
|
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}"
|
|
|
|
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
|
CLIENT_ID=$(getval CLIENT_ID)
|
|
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
|
|
# The one credential the local theta-agent needs; it exchanges this for its own
|
|
# token + the SSO public key on first connect (see 7c below).
|
|
AGENT_JOIN_KEY=$(getval AGENT_JOIN_KEY)
|
|
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
|
|
|
|
if [[ "$ALREADY_CONFIGURED" == "1" ]]; then
|
|
info "Stack was already configured — OAuth client creds in proxy-secrets.js are current."
|
|
else
|
|
info "OAuth client registered + creds written into $CONFIG_DIR/proxy-secrets.js."
|
|
fi
|
|
|
|
# ── 6. Start the proxy, wait for health ───────────────────────────────────────
|
|
# PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above.
|
|
PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
export PROXY_GIT_COMMIT
|
|
env_upsert PROXY_GIT_COMMIT "$PROXY_GIT_COMMIT"
|
|
info "Building + starting proxy (first run builds the image; this takes a while)..."
|
|
"${COMPOSE[@]}" up -d --build proxy
|
|
|
|
info "Waiting for proxy to be healthy..."
|
|
for i in $(seq 1 60); do
|
|
if docker exec proxy curl -fsS http://localhost:3000/health >/dev/null 2>&1; then
|
|
info "proxy is healthy."; break
|
|
fi
|
|
if (( i == 60 )); then die "proxy did not become healthy in 60s. Check: ${COMPOSE[*]} logs proxy"; fi
|
|
sleep 2
|
|
done
|
|
|
|
# ── 7. Register the SSO + proxy UIs as Host records in the proxy ──────────────
|
|
# The proxy routes EVERY hostname it serves — including its own management UI
|
|
# and the SSO's UI — off a Host record (ops/nginx_conf/proxy.conf has no
|
|
# default/self route; targetinfo.lua does a lookup for every request, full
|
|
# stop). Nothing else creates these two, so without this step https://<SSO_HOST>
|
|
# and https://<PROXY_HOST> 404 on first run. sso_enabled is left false on both:
|
|
# each app gates its own login already, and SSO-gating the SSO's own login page
|
|
# would be circular. Idempotent — skips a host that already exists.
|
|
info "Registering ${SSO_HOST} and ${PROXY_HOST} with the proxy..."
|
|
HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF
|
|
const {Host} = require('/app/models').models;
|
|
|
|
async function ensureHost(host, ip, targetPort) {
|
|
try {
|
|
await Host.get(host);
|
|
console.log('SKIP ' + host + ' (already exists)');
|
|
} catch (error) {
|
|
if (error.name !== 'EntryNotFound') throw error;
|
|
await Host.create({
|
|
host: host,
|
|
ip: ip,
|
|
targetPort: targetPort,
|
|
forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ),
|
|
targetssl: false,
|
|
sso_enabled: false,
|
|
created_by: 'setup.sh',
|
|
});
|
|
console.log('CREATED ' + host + ' -> ' + ip + ':' + targetPort);
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
await ensureHost($(js_str "$SSO_HOST"), 'sso-manager', 3001);
|
|
await ensureHost($(js_str "$PROXY_HOST"), '127.0.0.1', 3000);
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error('ERROR', error.message);
|
|
process.exit(1);
|
|
}
|
|
})();
|
|
NODEEOF
|
|
) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}"
|
|
echo "$HOSTS_OUT" | sed 's/^/[setup] /'
|
|
|
|
# ── 7b. Build + start the SSH jump host ──────────────────────────────────────
|
|
# The jump host is a core component (no longer optional). The bootstrap (step 5)
|
|
# already wrote ./config/jump-secrets.js (minted API token + LDAP admin bind) and
|
|
# mirrored it into OpenBao. Build/start the service, wait for its web /health,
|
|
# and register its web UI hostname as a proxy Host so https://<JUMP_HOST> routes.
|
|
JUMP_HOST="${CFG_JUMP_HOST:-jump.${SSO_HOST#sso.}}"
|
|
JUMP_GIT_COMMIT="$(git -C jump-host rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
export JUMP_GIT_COMMIT
|
|
env_upsert JUMP_GIT_COMMIT "$JUMP_GIT_COMMIT"
|
|
# Seed jump-host/conf from the file bootstrap just wrote (it mints the API
|
|
# token + OAuth client into /config/jump-secrets.js at step 5). bootstrap also
|
|
# writes this to OpenBao directly, so this is a fallback for when bootstrap's
|
|
# jump provisioning warned-but-continued.
|
|
seed_app_conf jump-host/conf /config/jump-secrets.js
|
|
info "Building + starting jump-host..."
|
|
"${COMPOSE[@]}" up -d --build jump-host
|
|
|
|
info "Waiting for jump-host to be healthy..."
|
|
for i in $(seq 1 60); do
|
|
if docker exec jump-host node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" >/dev/null 2>&1; then
|
|
info "jump-host is healthy."; break
|
|
fi
|
|
if (( i == 60 )); then warn "jump-host did not become healthy in 120s. Check: ${COMPOSE[*]} logs jump-host"; break; fi
|
|
sleep 2
|
|
done
|
|
|
|
info "Registering ${JUMP_HOST} (jump-host web UI) with the proxy..."
|
|
JUMP_HOSTS_OUT=$("${COMPOSE[@]}" exec -T proxy node <<NODEEOF || true
|
|
const {Host} = require('/app/models').models;
|
|
(async () => {
|
|
try {
|
|
try { await Host.get($(js_str "$JUMP_HOST")); console.log('SKIP ${JUMP_HOST} (already exists)'); }
|
|
catch (e) {
|
|
if (e.name !== 'EntryNotFound') throw e;
|
|
await Host.create({ host: $(js_str "$JUMP_HOST"), ip: 'jump-host', targetPort: 3002, forcessl: $( [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && echo false || echo true ), targetssl: false, sso_enabled: false, created_by: 'setup.sh' });
|
|
console.log('CREATED ${JUMP_HOST} -> jump-host:3002');
|
|
}
|
|
process.exit(0);
|
|
} catch (error) { console.error('ERROR', error.message); process.exit(1); }
|
|
})();
|
|
NODEEOF
|
|
)
|
|
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
|
|
|
|
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
|
|
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
|
|
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
|
|
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
|
info "Setting up theta-agent on the host..."
|
|
(
|
|
cd theta-agent || exit 0
|
|
# Install the prebuilt binary that ships in the theta-agent submodule (the
|
|
# repo's own install.sh uses the same release binary). We do NOT build from
|
|
# source here: a previous `go build -o theta-agent main.go websocket.go
|
|
# config.go` omitted executor.go/telemetry.go, failed to compile, and was
|
|
# silently skipped, so the agent was never installed.
|
|
if [[ ! -f "theta-agent-linux-amd64" ]]; then
|
|
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
|
|
else
|
|
info " Installing prebuilt theta-agent binary..."
|
|
if [[ -x "theta-agent-linux-amd64" ]]; then
|
|
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
|
|
sudo mkdir -p /etc/theta42
|
|
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
|
sudo cp agent.yml.example /etc/theta42/agent.yml
|
|
# Write the JOIN KEY, not a locally-invented token. The SSO
|
|
# only accepts credentials it issued, so the random token
|
|
# this used to generate could never authenticate -- the
|
|
# agent looped on "close 4001: Unauthorized" forever. The
|
|
# agent swaps this key for its own token (and the public key
|
|
# it must pin) on first connect and rewrites this file.
|
|
if [[ -n "$AGENT_JOIN_KEY" ]]; then
|
|
# Only the join key is written. The agent exchanges it
|
|
# for its own token + the SSO public key on first
|
|
# connect and rewrites this file itself.
|
|
#
|
|
# This used to sed a locally generated random value into
|
|
# auth_token. The SSO only accepts credentials it
|
|
# issued, so that token could never authenticate and the
|
|
# agent looped on "close 4001: Unauthorized" forever.
|
|
if sudo grep -q '^join_key:' /etc/theta42/agent.yml; then
|
|
sudo sed -i "s|^join_key:.*|join_key: \"${AGENT_JOIN_KEY}\"|" /etc/theta42/agent.yml
|
|
else
|
|
echo "join_key: \"${AGENT_JOIN_KEY}\"" | sudo tee -a /etc/theta42/agent.yml >/dev/null
|
|
fi
|
|
# Older agent.yml.example shipped REPLACE_WITH_* placeholders;
|
|
# blank them so they are not mistaken for real credentials.
|
|
sudo sed -i "s|REPLACE_WITH_ISSUED_AGENT_TOKEN||; s|REPLACE_WITH_AGENT_TOKEN||; s|REPLACE_WITH_SSO_PUBLIC_KEY||" /etc/theta42/agent.yml
|
|
else
|
|
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
|
fi
|
|
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
|
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
|
|
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
|
|
else
|
|
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
|
fi
|
|
sudo chmod 600 /etc/theta42/agent.yml
|
|
fi
|
|
# Stop a running agent before overwriting its binary (cp into a
|
|
# running executable fails with "Text file busy" on a re-install).
|
|
sudo systemctl stop theta-agent.service 2>/dev/null || true
|
|
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
|
|
sudo chmod +x /usr/local/bin/theta-agent
|
|
|
|
sudo bash -c "cat <<'EOF' > /etc/systemd/system/theta-agent.service
|
|
[Unit]
|
|
Description=Theta Agent
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=/usr/local/bin/theta-agent
|
|
Restart=on-failure
|
|
RestartSec=5
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF"
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now theta-agent.service
|
|
info " theta-agent installed and started."
|
|
fi
|
|
fi
|
|
)
|
|
else
|
|
info "theta-agent installation skipped (CFG_THETA_AGENT_ENABLE=0)."
|
|
fi
|
|
# ── 7d. Configure theta-agent integration with this host ─────────────────────
|
|
# Non-interactive configuration driven by setup.env variables:
|
|
# CFG_THETA_AGENT_ENABLE (default: 1) - Install/configure theta-agent
|
|
# CFG_THETA_AGENT_LDAP_AUTH (default: 1) - Configure LDAP authentication via ldap-client
|
|
# CFG_THETA_AGENT_FULL_CONTROL (default: 1) - Enable all agent capabilities
|
|
# Only runs if theta-agent was installed (section 7c) or already exists.
|
|
if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]; then
|
|
info "Configuring theta-agent integration with this host..."
|
|
|
|
# Default to enabled unless explicitly disabled
|
|
CFG_THETA_AGENT_LDAP_AUTH="${CFG_THETA_AGENT_LDAP_AUTH:-1}"
|
|
CFG_THETA_AGENT_FULL_CONTROL="${CFG_THETA_AGENT_FULL_CONTROL:-1}"
|
|
|
|
if [[ "$CFG_THETA_AGENT_LDAP_AUTH" == "1" ]]; then
|
|
info " Configuring LDAP authentication for this host..."
|
|
|
|
# ldap-client/index.sh refuses to run without ./ldap.vars, which is
|
|
# gitignored and never shipped in the checkout (it holds a real bind
|
|
# password). On the agent-enrollment path we generate it from the stack's
|
|
# own config so the host can actually enroll; an operator-provided
|
|
# ldap.vars (cp ldap.vars.template ldap.vars + edit) is always kept.
|
|
if [[ ! -f ldap-client/ldap.vars ]]; then
|
|
info " Generating ldap-client/ldap.vars from the stack config..."
|
|
# CFG_* first-run vars may be unset on a re-run (ensure_config returns
|
|
# early once sso-secrets.js exists), so fall back to reading the real
|
|
# values from the operator-owned sso-secrets.js. All `:-` guarded so a
|
|
# missing value degrades to an empty ldap.vars field, not a set -u abort.
|
|
ldap_base_dn="${CFG_BASE_DN:-$(sso_secrets_get ldapBaseDn)}"
|
|
ldap_site="${CFG_SITE_NAME:-$(sso_secrets_get siteName)}"
|
|
ldap_bind_pass="${CFG_SVC_PASS:-$(sso_secrets_get_top serviceAccountPass)}"
|
|
sso_host="${CFG_SSO_HOST:-$(sso_secrets_get ssoHost)}"
|
|
# The LDAP server is co-located with the stack on THIS host, so the
|
|
# host must reach it over the loopback / a local address -- NEVER the
|
|
# public domain (sso.<domain>), which cannot route back to the 389/636
|
|
# ports through NAT. localhost is fine because the generated sssd.conf
|
|
# sets ldap_tls_reqcert=never (hostname verification is off). An
|
|
# operator may override with CFG_LDAPS_HOST (an internal hostname/IP).
|
|
ldaps_host="${CFG_LDAPS_HOST:-localhost}"
|
|
cat > ldap-client/ldap.vars <<LDAPVARS
|
|
export ldap_host="${ldaps_host}"
|
|
export ldap_base_dn="${ldap_base_dn}"
|
|
export ldap_bind_dn="cn=ldapclient,ou=people,${ldap_base_dn}"
|
|
export ldap_bind_password="${ldap_bind_pass}"
|
|
export sso_url="https://${sso_host}"
|
|
export sso_token=""
|
|
export ldap_location="${ldap_site:-local}"
|
|
# Groups that grant SSH/access on this host (docs/GROUPS.md §8): the site's
|
|
# all-hosts aggregate, this host's own access group, and god_admin.
|
|
ldap_access_groups=( "site_\${ldap_location}_hosts_access" "site_\${ldap_location}_host_\$(hostname)_access" "god_admin" )
|
|
LDAPVARS
|
|
else
|
|
info " ldap-client/ldap.vars exists -- keeping it"
|
|
fi
|
|
|
|
(
|
|
cd ldap-client || exit 0
|
|
if [[ -x "index.sh" ]]; then
|
|
bash index.sh --non-interactive 2>/dev/null || warn " ldap-client enrollment failed (continuing)..."
|
|
fi
|
|
)
|
|
else
|
|
info " LDAP authentication configuration skipped (CFG_THETA_AGENT_LDAP_AUTH=0)."
|
|
fi
|
|
|
|
if [[ "$CFG_THETA_AGENT_FULL_CONTROL" == "1" ]]; then
|
|
info " Configuring theta-agent with full host control capabilities..."
|
|
if [[ -f /etc/theta42/agent.yml ]]; then
|
|
sudo sed -i 's/arbitrary_bash: false/arbitrary_bash: true/' /etc/theta42/agent.yml
|
|
# service_control is a []string allowlist (NOT a bool) — setting it to
|
|
# `true` makes the agent fail YAML decode and crash-loop. There is no
|
|
# wildcard; leave the operator's list (or the [] default = deny all)
|
|
# alone and document how to enable specific services.
|
|
# sudo sed -i 's/service_control: .*/service_control: true/' ...
|
|
sudo sed -i 's/reboot: false/reboot: true/' /etc/theta42/agent.yml
|
|
sudo sed -i 's/configure_ldap: false/configure_ldap: true/' /etc/theta42/agent.yml
|
|
info " (service_control left as its allowlist; set e.g. service_control: [\"nginx\"] in /etc/theta42/agent.yml to permit managing specific services)"
|
|
info " theta-agent full control enabled. Restarting service..."
|
|
sudo systemctl restart theta-agent.service
|
|
else
|
|
warn " /etc/theta42/agent.yml not found. Full control not configured."
|
|
fi
|
|
else
|
|
info " theta-agent running with limited capabilities (CFG_THETA_AGENT_FULL_CONTROL=0)."
|
|
fi
|
|
else
|
|
info " theta-agent configuration skipped (agent not installed or CFG_THETA_AGENT_ENABLE=0)."
|
|
fi
|
|
|
|
# ── 8. Summary ───────────────────────────────────────────────────────────────
|
|
echo
|
|
printf '\033[1;34m[setup]\033[0m \033[1;32mDone. Your SSO + proxy stack is up.\033[0m\n'
|
|
echo
|
|
echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS)"
|
|
echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
|
|
echo " Proxy mgmt UI: https://${PROXY_HOST}"
|
|
echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}"
|
|
echo " Jump host (SSH): ssh -p ${JUMP_SSH_PORT:-2222} <uid>@${JUMP_HOST:-jump.${SSO_HOST#sso.}} (TUI picker)"
|
|
echo " ssh -p ${JUMP_SSH_PORT:-2222} <uid>_-_<host>@${JUMP_HOST:-jump.${SSO_HOST#sso.}}"
|
|
echo " Jump host (web): https://${JUMP_HOST:-jump.${SSO_HOST#sso.}} (audit + metrics)"
|
|
echo
|
|
echo " First admin login credentials are in ./config/sso-secrets.js:"
|
|
echo " user: ${ADMIN_UID}"
|
|
echo " pass: ${CFG_ADMIN_PASS:-<see ./config/sso-secrets.js>}"
|
|
echo
|
|
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
|
|
echo " user: proxyadmin2"
|
|
echo " pass: auth.localAdminPass in ./config/proxy-secrets.js"
|
|
echo " (only shown when the account is first created; edit ./config/proxy-secrets.js"
|
|
echo " or use the proxy UI to change it afterward)"
|
|
echo
|
|
echo " Secrets live in ./config/ (sso-secrets.js + proxy-secrets.js). Back them"
|
|
echo " up off-host — ./setup.sh snapshots to ./backups/ before each rebuild."
|
|
echo
|
|
echo " Next: add DNS records (or /etc/hosts) pointing ${SSO_HOST} and ${PROXY_HOST}"
|
|
echo " at this host, then open https://${SSO_HOST} and log in as the admin."
|
|
echo " The proxy auto-issues Let's Encrypt certs if port 80 is reachable;"
|
|
echo " otherwise it serves a self-signed fallback on the LAN."
|
|
echo
|
|
echo " Re-run ./setup.sh any time to converge the stack to ./config/ (idempotent)." |