ca812bed8e
Two real bugs found on a live deployment: 1. setup.sh's theta-agent install step sed'd in join_key but never touched server_url, so /etc/theta42/agent.yml kept agent.yml.example's literal "https://sso.example.com" placeholder forever. Fixed for both first install and an already-installed agent.yml (self-heals server_url only, never touches join_key/auth_token, which may since have been rewritten by the agent itself with real issued credentials). 2. `theta-agent update` 404'd downloading https://sso.../resources/theta-agent/theta-agent-linux-amd64 -- that route never existed server-side (only /resources/theta-agent/install.sh is static-served); self-update itself was already fixed upstream to pull from GitHub Releases, but setup.sh was still installing the binary committed in the theta-agent submodule checkout, which predated that fix and could therefore never self-update out of the bug. Switched setup.sh to download the current release binary from GitHub instead (matching theta-agent's own install.sh), and bumped the submodule to theta-agent's latest commit, which removes the stale committed binaries entirely -- this exact "stale committed binary" bug class has bitten this repo at least twice before (see theta-agent's CHANGELOG v1.5.0 entry).
1555 lines
76 KiB
Bash
Executable File
1555 lines
76 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
|
|
SEED_NODE_SECRET=0
|
|
SEED_NODE_ARGS=()
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--reset-openbao) RESET_OPENBAO=1 ;;
|
|
--seed-node-secret) SEED_NODE_SECRET=1 ;;
|
|
*) if [[ "$SEED_NODE_SECRET" == 1 ]]; then SEED_NODE_ARGS+=("$arg"); else warn "unknown argument: $arg (ignored)"; fi ;;
|
|
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:-Theta Directory}"
|
|
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)"
|
|
}
|
|
|
|
# Seed a node-scoped secret for a theta-agent (DESIGN.md §5). Node secrets live
|
|
# at secret/data/nodes/<agent-id>/* and are read by the agent (via the SSO's
|
|
# /api/v1/agent/secrets) on behalf of 3rd-party apps on the host. Agent ids are
|
|
# minted at enrollment, so this is a helper the operator calls per node, not a
|
|
# boot-time seed:
|
|
# ./setup.sh --seed-node-secret <agent-id> <name> <key>=<value> [<key>=<value>...]
|
|
seed_node_conf() {
|
|
local agent_id="$1" name="$2"; shift 2
|
|
[[ -n "$agent_id" && -n "$name" ]] || die "seed_node_conf: need <agent-id> <name>"
|
|
# CLI paths are mount-relative (no "data/" segment -- the CLI inserts that
|
|
# itself for KV v2, same as seed_app_conf's "secret/${vault_path}" above).
|
|
# The HTTP API path api_agent_ops.js checks against (secret/data/nodes/...)
|
|
# is what this resolves to underneath.
|
|
local path="secret/nodes/${agent_id}/${name}"
|
|
if bao_run kv get "$path" >/dev/null 2>&1; then
|
|
info " ${path} already seeded — keeping."
|
|
return 0
|
|
fi
|
|
info "Seeding ${path}..."
|
|
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put "$path" "$@" >/dev/null \
|
|
|| die "failed to seed ${path}"
|
|
}
|
|
|
|
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"] }
|
|
# Node-scoped secrets for theta-agent (DESIGN.md §5): each node reads only its
|
|
# own secret/data/nodes/<agent-id>/* subtree via the SSO's /api/v1/agent/secrets
|
|
# endpoint. The SSO (sso-broker) must be able to read them on the agent's behalf.
|
|
path "secret/data/resources/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/resources/*" { capabilities = ["list", "read", "delete"] }
|
|
path "secret/data/nodes/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "secret/metadata/nodes/*" { 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/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "sys/policies/acl" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "sys/policy/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
|
path "sys/policy" { 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)"
|
|
|
|
# --seed-node-secret <agent-id> <name> <key>=<value>... : seed a node-scoped
|
|
# secret for a theta-agent (DESIGN.md §5). Runs after OpenBao is configured so
|
|
# the sso-broker policy (which grants secret/data/nodes/*) is in place.
|
|
if [[ "$SEED_NODE_SECRET" == 1 ]]; then
|
|
seed_node_conf "${SEED_NODE_ARGS[@]}"
|
|
fi
|
|
|
|
# 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
|
|
|
|
# ── 5b. Multi-site: join an existing master directory (first-run only) ────────
|
|
# setup.env: CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY (mint a
|
|
# site join key on the master). Only honored on a first-run bring-up: ensure_config
|
|
# reads setup.env once and ignores it once ./config/ exists, so an already-running
|
|
# directory can never be merged into a master's. Idempotent — a node that already
|
|
# joined reports "already a spoke" and setup continues.
|
|
if [[ -n "${CFG_MASTER_DIRECTORY_URL:-}" && -n "${CFG_MASTER_DIRECTORY_JOIN_KEY:-}" ]]; then
|
|
info "Joining master site ${CFG_MASTER_DIRECTORY_URL} (CFG_MASTER_DIRECTORY_*)..."
|
|
# selfUrl (https://$CFG_SSO_HOST, already derived above) registers this
|
|
# spoke for LIVE replication -- without it the join still succeeds, but
|
|
# the master has no way to reach this spoke to push resync pings, so it
|
|
# only ever gets the one-time snapshot from the moment it joined.
|
|
if ! "${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-join.js \
|
|
"$CFG_MASTER_DIRECTORY_URL" "$CFG_MASTER_DIRECTORY_JOIN_KEY" "https://$CFG_SSO_HOST"; then
|
|
die "site join failed — check the master URL + site join key (mint one on the master's Site Join Keys card)."
|
|
fi
|
|
else
|
|
info "No CFG_MASTER_DIRECTORY_URL/CFG_MASTER_DIRECTORY_JOIN_KEY — running as a fresh master site."
|
|
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] /'
|
|
|
|
# ── 7b2. No-inbound relay registration (first-run *and* every re-run) ─────────
|
|
# CFG_SPOKE_NO_INBOUND: this site has no public IP, so the master relays to it
|
|
# over the gateway-to-gateway WireGuard mesh (MULTI_SITE_SPEC.md §5.2). The
|
|
# mesh peering itself is a manual, out-of-band step on both jump-hosts (mint a
|
|
# join token on the master's jump-host, paste it into this site's jump-host
|
|
# "Join a mesh" UI action) -- it can't run unattended here, and it commonly
|
|
# happens AFTER this first setup.sh run finishes. So this step runs on every
|
|
# invocation, not just first-run: it discovers this jump-host's mesh IP and
|
|
# (re-)registers it with the master, and is a no-op until meshing is done.
|
|
if [[ "${CFG_SPOKE_NO_INBOUND:-false}" == "true" ]]; then
|
|
if [[ -z "${CFG_SPOKE_PUBLIC_HOST:-}" ]]; then
|
|
warn "CFG_SPOKE_NO_INBOUND=true but CFG_SPOKE_PUBLIC_HOST is unset — skipping relay registration."
|
|
else
|
|
info "Checking no-inbound relay registration (CFG_SPOKE_PUBLIC_HOST=${CFG_SPOKE_PUBLIC_HOST})..."
|
|
"${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-relay-register.js \
|
|
"https://$CFG_SSO_HOST" "$CFG_SPOKE_PUBLIC_HOST" || warn "relay registration did not complete — check: ${COMPOSE[*]} exec sso-manager node /bootstrap/site-relay-register.js https://$CFG_SSO_HOST $CFG_SPOKE_PUBLIC_HOST"
|
|
fi
|
|
fi
|
|
|
|
# ── 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
|
|
# Download the current release binary from GitHub rather than trusting a
|
|
# binary committed in the submodule checkout. A committed binary drifts:
|
|
# theta-agent's own `theta-agent update` moved to pulling from GitHub
|
|
# Releases (DESIGN-WINDOWS.md §9, "nothing binary lives in the repos")
|
|
# once, but this script kept installing the stale binary that shipped
|
|
# with an old submodule pin, which still pointed `update` at a dead
|
|
# SSO /resources/ URL that never existed server-side -- so an agent
|
|
# installed this way could never even self-update out of the bug. We
|
|
# do NOT build from source here either: 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.
|
|
AGENT_BIN_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
|
AGENT_BIN_TMP="$(mktemp)"
|
|
info " Downloading latest theta-agent-linux-amd64 release binary..."
|
|
if ! curl -fsSL -o "$AGENT_BIN_TMP" "$AGENT_BIN_URL" || [[ ! -s "$AGENT_BIN_TMP" ]]; then
|
|
rm -f "$AGENT_BIN_TMP"
|
|
warn "Could not download theta-agent-linux-amd64 from $AGENT_BIN_URL. Skipping theta-agent installation."
|
|
else
|
|
chmod +x "$AGENT_BIN_TMP"
|
|
info " Installing theta-agent binary..."
|
|
if true; 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.
|
|
# Without this, agent.yml keeps agent.yml.example's literal
|
|
# "https://sso.example.com" placeholder forever -- nothing
|
|
# else in this block ever touched server_url, only join_key.
|
|
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
|
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
|
sudo getent group theta-secrets >/dev/null 2>&1 || sudo groupadd -r theta-secrets 2>/dev/null || true
|
|
sudo getent group theta >/dev/null 2>&1 || sudo groupadd -r theta 2>/dev/null || true
|
|
SECRETS_GRP="root"
|
|
if getent group theta-secrets >/dev/null 2>&1; then SECRETS_GRP="theta-secrets"; elif getent group theta >/dev/null 2>&1; then SECRETS_GRP="theta"; fi
|
|
sudo chown -R "root:$SECRETS_GRP" /etc/theta42 2>/dev/null || true
|
|
sudo chmod 750 /etc/theta42
|
|
sudo chmod 640 /etc/theta42/agent.yml
|
|
else
|
|
# Self-heal an already-installed agent.yml that predates the
|
|
# server_url fix above -- it would otherwise keep whatever
|
|
# placeholder/stale host it was first installed with
|
|
# forever, since nothing else in this script ever revisits
|
|
# an existing agent.yml. Never touches join_key/auth_token:
|
|
# those may since have been rewritten by the agent itself
|
|
# with real issued credentials.
|
|
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
|
if sudo grep -q '^server_url:' /etc/theta42/agent.yml; then
|
|
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
|
fi
|
|
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 "$AGENT_BIN_TMP" /usr/local/bin/theta-agent
|
|
sudo chmod +x /usr/local/bin/theta-agent
|
|
rm -f "$AGENT_BIN_TMP"
|
|
|
|
# Install desktop tray companion if available
|
|
TRAY_SRC="dist/theta-agent-tray-linux-amd64"
|
|
if [[ ! -f "$TRAY_SRC" ]] && [[ -f "theta-agent-tray-linux-amd64" ]]; then TRAY_SRC="theta-agent-tray-linux-amd64"; fi
|
|
if [[ -f "$TRAY_SRC" ]]; then
|
|
sudo cp "$TRAY_SRC" /usr/local/bin/theta-agent-tray
|
|
sudo chmod +x /usr/local/bin/theta-agent-tray
|
|
sudo mkdir -p /etc/xdg/autostart
|
|
sudo bash -c "cat <<'EOF' > /etc/xdg/autostart/theta-agent-tray.desktop
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=Theta Agent Tray
|
|
Comment=Theta Agent Desktop Tray Companion
|
|
Exec=/usr/local/bin/theta-agent-tray
|
|
Icon=network-workgroup
|
|
Terminal=false
|
|
Categories=Utility;System;
|
|
X-GNOME-Autostart-enabled=true
|
|
EOF"
|
|
info " theta-agent-tray companion installed."
|
|
fi
|
|
|
|
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)." |