Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 60ba9ba989 docs: fix quickstart drift, add LICENSE, document admin bypass and LDAPS cert mount for public release
Cleanup pass ahead of the public release announcement:

- docs/index.md: fix the Quick Start block, which described a stale
  "edit config then re-run setup.sh a second time" flow. setup.sh now
  requires setup.env (with CFG_BASE_DN) before it will do anything, and
  builds + bootstraps + starts in a single run. Updated to match
  README.md's correct 4-line sequence.
- Add a standard MIT LICENSE at the repo root (theta42, 2026) so
  docs/index.md's "MIT License — see the repository for details" claim
  is actually true.
- docs/standalone.md: document the hardcoded auth.adminUsers:
  ['proxyadmin2'] local anti-lockout admin bypass written into every
  generated proxy-secrets.js — what it's for, that it requires a
  matching SSO user to actually use, and how to rename/extend/disable
  it.
- README.md + docker-compose.yml: fix the LDAPS strict-trust security
  note, which implied mounting the SSO's cert into the proxy was a
  config-only change. It also requires a docker-compose.yml edit
  (ldap-certs isn't mounted into the proxy service); added commented-out
  boilerplate for that mount and clarified the doc text.
- Also includes the pre-existing "Why use this instead of running the
  two separately?" README paragraph that was already staged as
  in-progress work.
- Verified: no Vagrant references, no emoji, and no hardcoded
  custom-domain URLs anywhere in this repo outside the proxy/ and
  sso-manager-node/ submodules; no docs/CNAME (github.io URL scheme
  confirmed).
- Added --- section dividers to docs/*.md to match README.md's
  formatting convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:00:32 -04:00
33 changed files with 242 additions and 3282 deletions
+4 -13
View File
@@ -14,8 +14,7 @@
LDAP_BASE_DN=dc=example,dc=com LDAP_BASE_DN=dc=example,dc=com
# DNS domain (dc=foo,dc=bar -> foo.bar). Leave blank to derive from LDAP_BASE_DN. # DNS domain (dc=foo,dc=bar -> foo.bar). Leave blank to derive from LDAP_BASE_DN.
LDAP_DOMAIN= LDAP_DOMAIN=
# LDAP admin password. MUST be changed. Leave blank and setup.sh will generate one. LDAP_ADMIN_PASS=change-me-ldap-admin-password
LDAP_ADMIN_PASS=CHANGE-ME
ORG_NAME="My Org" ORG_NAME="My Org"
# ── Public hostnames (REQUIRED) ─────────────────────────────────────────────── # ── Public hostnames (REQUIRED) ───────────────────────────────────────────────
@@ -31,15 +30,13 @@ PROXY_HOST=proxy.example.com
# app_sso_oauth_admin, and logs in as them to register the proxy OAuth client. # app_sso_oauth_admin, and logs in as them to register the proxy OAuth client.
# Re-running setup.sh resets this password to BOOTSTRAP_ADMIN_PASS. # Re-running setup.sh resets this password to BOOTSTRAP_ADMIN_PASS.
BOOTSTRAP_ADMIN_UID=admin BOOTSTRAP_ADMIN_UID=admin
# First admin password. MUST be changed. Leave blank and setup.sh will generate one. BOOTSTRAP_ADMIN_PASS=change-me-admin-password
BOOTSTRAP_ADMIN_PASS=CHANGE-ME
BOOTSTRAP_ADMIN_EMAIL=admin@example.com BOOTSTRAP_ADMIN_EMAIL=admin@example.com
# ── Proxy LDAP service account (created by the bootstrap) ──────────────────── # ── Proxy LDAP service account (created by the bootstrap) ────────────────────
# The proxy binds to LDAP as cn=ldapclient,ou=people,<base> with this password. # The proxy binds to LDAP as cn=ldapclient,ou=people,<base> with this password.
# Re-running setup.sh resets it to LDAP_SERVICE_PASS. # Re-running setup.sh resets it to LDAP_SERVICE_PASS.
# LDAP service-account password. MUST be changed. Leave blank and setup.sh will generate one. LDAP_SERVICE_PASS=change-me-ldap-service-password
LDAP_SERVICE_PASS=CHANGE-ME
# ── OAuth JWT secret (REQUIRED — persist it) ──────────────────────────────── # ── OAuth JWT secret (REQUIRED — persist it) ────────────────────────────────
# Signs the SSO's access/refresh tokens. Generate with: openssl rand -hex 32 # Signs the SSO's access/refresh tokens. Generate with: openssl rand -hex 32
@@ -76,10 +73,4 @@ MGMT_BIND=0.0.0.0
# Defaults to LDAP_DOMAIN. Set to the hostname the proxy connects via # Defaults to LDAP_DOMAIN. Set to the hostname the proxy connects via
# (sso-manager inside the docker net uses the service name, which is in the # (sso-manager inside the docker net uses the service name, which is in the
# cert's SAN, so the default is usually fine). # cert's SAN, so the default is usually fine).
LDAP_CERT_CN= LDAP_CERT_CN=
# ── Optional: LDAPS hostname shown on the SSO /integrations page ────────────────
# Leave blank to derive from the public SSO host (SSO_HOST). Set an internal-only
# name like 'ldap.internal.example.com' or 'sso-manager' so direct-LDAP clients
# don't need a public 636 port forward. See docs/ldap.md for network layouts.
LDAPS_HOST=
-46
View File
@@ -1,46 +0,0 @@
name: Lint
# theta-env has no app code of its own to unit-test (it orchestrates the
# proxy/sso-manager-node submodules) -- this checks the one thing that can
# actually break silently: setup.sh and bootstrap.js, plus a static
# consistency check on the config bootstrap.js generates for jump-host
# (test/check_jump_ldap_tls.js).
on:
pull_request:
branches:
- master
push:
branches-ignore:
- master
jobs:
shellcheck:
name: Shellcheck setup.sh
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Syntax check
run: bash -n setup.sh
- name: Shellcheck
run: shellcheck -S warning setup.sh
bootstrap-syntax:
name: Syntax check bootstrap.js
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Syntax check
run: node --check bootstrap/bootstrap.js
- name: Jump-host LDAP config consistency
run: node test/check_jump_ldap_tls.js
+3 -10
View File
@@ -5,12 +5,8 @@
config/ config/
backups/ backups/
# .env: NOT app config (that's ./config/, generated by setup.sh) — this is # Legacy .env / proxy.env (no longer used — config is in ./config/). Still
# docker compose's own auto-loaded env file, which setup.sh uses only to # ignored in case a migrated deployment hasn't deleted them yet.
# persist *_GIT_COMMIT build args so an ad-hoc rebuild of a single service
# still bakes the right commit hash. Generated; never commit.
# proxy.env: legacy, no longer used — still ignored in case an old
# deployment hasn't deleted it yet.
.env .env
proxy.env proxy.env
@@ -24,7 +20,4 @@ setup.env
*.ldif *.ldif
# Docker Compose runtime artifacts # Docker Compose runtime artifacts
*.log *.log
# Jekyll build output (docs/ site) — generated, not committed.
docs/_site
-7
View File
@@ -4,10 +4,3 @@
[submodule "proxy"] [submodule "proxy"]
path = proxy path = proxy
url = https://github.com/theta42/proxy.git url = https://github.com/theta42/proxy.git
[submodule "jump-host"]
path = jump-host
url = https://github.com/theta42/jump-host.git
branch = master
[submodule "ldap-client"]
path = ldap-client
url = https://github.com/theta42/ldap-client.git
-1187
View File
File diff suppressed because it is too large Load Diff
+30 -83
View File
@@ -16,28 +16,14 @@ Each project still runs **standalone** (`docker compose up` in its own folder);
this repo just composes them and automates the first-run glue so they find each this repo just composes them and automates the first-run glue so they find each
other. other.
**Documentation:** [https://theta42.github.io/theta-env/](https://theta42.github.io/theta-env/) **Why use this instead of running the two separately?** The two only become
useful once the proxy is registered as an OIDC client of the SSO and pointed at
## Screenshots the SSO's LDAP directory — and the SSO's domain has to match across half a dozen
config fields or logins silently fail with `Invalid Credentials`. Doing that by
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run: hand is fiddly and easy to get wrong. `setup.sh` asks for your domain once (in
`setup.env`), generates both config files with it filled in everywhere, registers
| SSO Manager Dashboard | Proxy Hosts | the proxy as an OIDC client, and snapshots state before every rebuild — so you
| --- | --- | get a working SSO + proxy stack in one command and a safe way to upgrade it.
| [![SSO Manager dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
## Configuration
`setup.sh` automates the first-run glue between subprojects:
- Asks for your domain once (in `setup.env`) and fills it in across all config files.
- Registers the proxy as an OIDC client of the SSO.
- Persists submodule commit hashes in `.env` for reproducibility (e.g., `SSO_GIT_COMMIT`, `PROXY_GIT_COMMIT`). This ensures future `docker compose` runs use the same submodule versions.
**Why use this instead of running the two separately?** The two only become useful once the proxy is registered as an OIDC client of the SSO and pointed at the SSO's LDAP directory — and the SSO's domain has to match across half a dozen config fields or logins silently fail with `Invalid Credentials`. Doing that by hand is fiddly and easy to get wrong. `setup.sh` handles this automatically and snapshots state before every rebuild — so you get a working SSO + proxy stack in one command and a safe way to upgrade it.
## Unified Release Status
-**Phase 1 (oidc-client)**: Complete.
-**Phases 2-5**: Pending (see [roadmap](#)).
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
@@ -64,10 +50,6 @@ It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). Legacy apps can still bind to LDAPS on the SSO client (for user lookups). Legacy apps can still bind to LDAPS on the SSO
directly. directly.
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
--- ---
## Before you begin ## Before you begin
@@ -82,10 +64,10 @@ real TLS certificates for it via Let's Encrypt. A `.local` or made-up name only
gets you a self-signed cert (browsers will warn — fine for testing, painful for gets you a self-signed cert (browsers will warn — fine for testing, painful for
daily use). daily use).
The domain is the **one** value you set in `setup.env` (e.g. The domain is the **one** value you set in `setup.env` (as the LDAP base DN,
`CFG_DOMAIN=lab.example.com`) — see *Quickstart*. The SSO/proxy hostnames e.g. `CFG_BASE_DN=dc=lab,dc=example,dc=com` for `lab.example.com`) — see
default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN *Quickstart*. The SSO/proxy hostnames default to `sso.<domain>` /
(`dc=lab,dc=example,dc=com`) is built from it automatically. `proxy.<domain>`, derived from it.
### 2. At least two hostnames, pointing at your public IP ### 2. At least two hostnames, pointing at your public IP
@@ -128,10 +110,6 @@ Optional extra ports (only if you need them):
- **636** (LDAPS) — only if a legacy app on another machine binds to LDAP - **636** (LDAPS) — only if a legacy app on another machine binds to LDAP
directly over the network. The proxy itself reaches LDAP over the internal directly over the network. The proxy itself reaches LDAP over the internal
Docker network, so you do **not** need to expose 636 for the stack to work. Docker network, so you do **not** need to expose 636 for the stack to work.
**Do not forward 636 to the public internet.** If you need LAN clients to bind
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
SAN. The default shows the public SSO hostname, which implies a public route.
### 4. Docker + Docker Compose ### 4. Docker + Docker Compose
@@ -145,13 +123,12 @@ standalone (`docker-compose`) both work.
```bash ```bash
git clone --recursive https://github.com/theta42/theta-env.git git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env cd theta-env
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
``` ```
Your domain is entered **once** in `setup.env` (e.g. Your domain is entered **once**, as the LDAP base DN in `setup.env` (e.g.
`CFG_DOMAIN=lab.example.com`) — the LDAP base DN (`dc=lab,dc=example,dc=com`) `CFG_BASE_DN=dc=lab,dc=example,dc=com` for the domain `lab.example.com`). The
is derived from it, however many labels it has. The
first `./setup.sh` reads `setup.env` and generates `./config/sso-secrets.js` + first `./setup.sh` reads `setup.env` and generates `./config/sso-secrets.js` +
`./config/proxy-secrets.js` with that domain filled in everywhere (hostnames `./config/proxy-secrets.js` with that domain filled in everywhere (hostnames
default to `sso.<domain>` / `proxy.<domain>`) plus random secrets, then builds default to `sso.<domain>` / `proxy.<domain>`) plus random secrets, then builds
@@ -174,28 +151,12 @@ operator-owned and `setup.env` is ignored.
- registers the proxy as an OIDC client in the SSO and **writes the generated - registers the proxy as an OIDC client in the SSO and **writes the generated
client id + secret back into `./config/proxy-secrets.js`**. client id + secret back into `./config/proxy-secrets.js`**.
4. Builds + starts the proxy container, waits for it to be healthy. 4. Builds + starts the proxy container, waits for it to be healthy.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy 5. Prints your first admin login + the public URLs.
(directly via its Host model, inside the proxy container) — the proxy
routes every hostname it serves off a Host record, including its own
management UI and the SSO's UI, so without this step those two URLs
would 404. Idempotent; skips a host that already exists.
6. Prints your first admin login + the public URLs.
### Configuration & secrets — OpenBao + `./config/` ### Configuration — `./config/` (no `.env` files)
Secrets live in **OpenBao** (a Vault fork, container `openbao:8200` on All config and secrets live in a bind-mounted `./config/` directory (gitignored),
`theta-net`), the single authoritative store. Each app loads them at boot with read by each app's `@simpleworkjs/conf` from a symlinked `secrets.js`:
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/<app>/conf` over the file-loaded config — **fail-soft**, so
if OpenBao is unreachable the app boots from the file fallback. End users get
personal per-user secret storage (`secret/users/<uid>/*`) in the SSO **Vault**
UI, and admins mint scoped tokens for external apps (`secret/apps/<name>/*`).
See **[docs/secrets.md](docs/secrets.md)** for the full architecture, policies,
token model, rotation, and the external-app convention.
A bind-mounted `./config/` directory (gitignored) holds the operator-edit
seed files and the fail-soft fallback, read by each app's `@simpleworkjs/conf`
via the `CONF_SECRETS` env var:
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password, - **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
@@ -206,15 +167,11 @@ via the `CONF_SECRETS` env var:
same `serviceAccountPass`), `auth` (admin groups/users). same `serviceAccountPass`), `auth` (admin groups/users).
`./setup.sh` generates both on first run from `./setup.env` (the one place the `./setup.sh` generates both on first run from `./setup.env` (the one place the
domain is entered — see *Quickstart*) with random secrets, seeds them into domain is entered — see *Quickstart*) with random secrets. There is **no
OpenBao, and mints scoped per-app tokens (`SSO_VAULT_TOKEN` / `.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates
`PROXY_VAULT_TOKEN` / `JUMP_VAULT_TOKEN`) into `./.env`. There is **no port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the
`.env` / `proxy.env`** for app config — edit `./config/*.js` directly (and command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the
re-seed into OpenBao, or use the SSO Configuration UI for live edits). Compose full annotated shape, and each submodule's `secrets.js.example`.
only interpolates port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can
override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
`config.example/` for the full annotated shape, and each submodule's
`secrets.js.example`.
> **Migrating from an older `.env`-based deployment?** If `.env` and/or > **Migrating from an older `.env`-based deployment?** If `.env` and/or
> `proxy.env` exist when you first run `./setup.sh`, it migrates them into > `proxy.env` exist when you first run `./setup.sh`, it migrates them into
@@ -343,11 +300,8 @@ docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb
docker compose exec proxy redis-cli BGSAVE docker compose exec proxy redis-cli BGSAVE
docker compose cp proxy:/data/dump.rdb proxy.rdb docker compose cp proxy:/data/dump.rdb proxy.rdb
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative) # Secrets
cp -a ./config config-backup && chmod 700 config-backup cp -a ./config config-backup && chmod 700 config-backup
# OpenBao (the authoritative secret store — back up its data volume)
docker run --rm -v theta-env_openbao-data:/data -v "$PWD":/backup alpine \
tar czf /backup/openbao-data.tgz -C /data .
``` ```
### Restore — full disaster recovery ### Restore — full disaster recovery
@@ -487,7 +441,7 @@ exactly in the bootstrap) so the SSO can verify them on bind.
``` ```
theta-env/ theta-env/
├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN ├── setup.env.example # first-run config template — cp to setup.env, set CFG_BASE_DN
├── config.example/ # committed annotated config templates (copy to ./config/) ├── config.example/ # committed annotated config templates (copy to ./config/)
├── docker-compose.yml # sso-manager + proxy on one bridge net ├── docker-compose.yml # sso-manager + proxy on one bridge net
├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups) ├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups)
@@ -501,14 +455,7 @@ theta-env/
gitignored `./config/` (`sso-secrets.js` + `proxy-secrets.js`) and snapshots to gitignored `./config/` (`sso-secrets.js` + `proxy-secrets.js`) and snapshots to
the gitignored `./backups/` before each rebuild. the gitignored `./backups/` before each rebuild.
`./setup.sh` updates both submodules to their latest `vX.Y.Z` release tag `./setup.sh` updates both submodules to the latest of their tracked remote
before building — not the tip of `master` — so each run builds the newest branch before building, so each run builds current upstream — no manual
tagged release of each app, not whatever's most recently merged upstream. To `git submodule update --remote` needed. To lock to the pinned commits (offline
lock to the pinned commits (offline rebuild, or a deliberate pin), run rebuild, or a deliberate pin), run `SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-env release
(and each submodule's own `CHANGELOG.md` —
[proxy](https://github.com/theta42/proxy/blob/master/CHANGELOG.md),
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)
— for what changed inside the apps themselves).
+9 -424
View File
@@ -23,13 +23,6 @@
* into the file (the sso-manager mounts ./config * into the file (the sso-manager mounts ./config
* read-write for this purpose). * read-write for this purpose).
* *
* Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when
* the jump host is enabled, secret/jump-host/conf) so the proxy + jump host
* load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes
* the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is
* fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config
* file remains the fallback and bootstrap does not fail the bring-up over it.
*
* Idempotent: re-running converges to the ./config values. The LDAP service * Idempotent: re-running converges to the ./config values. The LDAP service
* account + admin passwords are reset to the file values on each run; the * account + admin passwords are reset to the file values on each run; the
* OAuth client is created if missing. If proxy-secrets.js already holds a * OAuth client is created if missing. If proxy-secrets.js already holds a
@@ -51,15 +44,8 @@ const fs = require('fs');
const sso = require('/config/sso-secrets.js'); const sso = require('/config/sso-secrets.js');
const proxy = require('/config/proxy-secrets.js'); const proxy = require('/config/proxy-secrets.js');
function requireConf(value, name) { const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com';
if (value === undefined || value === null || value === '' || value === 'CHANGE-ME') { const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin';
throw new Error(`${name} is not configured in /config/sso-secrets.js`);
}
return value;
}
const BASE_DN = requireConf((sso.stack && sso.stack.ldapBaseDn), 'stack.ldapBaseDn');
const ADMIN_PASS = requireConf((sso.ldap && sso.ldap.bindPassword), 'ldap.bindPassword');
const BIND_DN = `cn=admin,${BASE_DN}`; const BIND_DN = `cn=admin,${BASE_DN}`;
const LDAP_URL = 'ldap://localhost:389'; const LDAP_URL = 'ldap://localhost:389';
@@ -67,9 +53,9 @@ const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
// The first admin *user's* password (cn=<uid>,ou=people,<base>). Distinct from // The first admin *user's* password (cn=<uid>,ou=people,<base>). Distinct from
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password — // ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password —
// two different accounts, two different secrets. // two different accounts, two different secrets.
const ADMIN_USER_PASS = requireConf((sso.bootstrap && sso.bootstrap.adminPass), 'bootstrap.adminPass'); const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin';
const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || ''; const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || '';
const SVC_PASS = requireConf(sso.serviceAccountPass, 'serviceAccountPass'); const SVC_PASS = sso.serviceAccountPass || 'service';
const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com'; const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com';
const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com'; const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com';
@@ -94,44 +80,6 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin'];
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n'); const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
const out = (k, v) => process.stdout.write(`${k}=${v}\n`); const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
// ── OpenBao (Vault) writes ───────────────────────────────────────────────────
// bootstrap generates the proxy's + jump host's OAuth client creds and writes
// them back into /config/*-secrets.js (the file fallback). It ALSO writes the
// complete conf into OpenBao so the proxy + jump host load it from there at
// boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this
// exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write
// is skipped with a warning — the file remains the fallback and bootstrap does
// not fail the bring-up over it.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const VAULT_TOKEN = process.env.VAULT_TOKEN || '';
// Re-require a /config module after its file has been rewritten on disk
// (require caches the old contents otherwise).
function freshRequire(p) {
delete require.cache[require.resolve(p)];
return require(p);
}
// PUT (replace) the data at secret/data/<vaultPath> with `data`. Warn-only.
async function baoPut(vaultPath, data) {
if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; }
try {
const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, {
method: 'POST',
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`);
} else {
log(`OpenBao: wrote secret/${vaultPath}`);
}
} catch (e) {
log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`);
}
}
// Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the // Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the
// directory stores passwords the SSO can verify on bind (pw-sha2 module). // directory stores passwords the SSO can verify on bind (pw-sha2 module).
function hashPasswordSSHA512(password) { function hashPasswordSSHA512(password) {
@@ -274,15 +222,14 @@ async function listClients(token) {
return (data && data.results) || []; return (data && data.results) || [];
} }
async function createClient(token, opts) { async function createClient(token) {
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, { const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST', method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' }, headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: o.name, name: CLIENT_NAME,
description: o.description, description: 'theta-env proxy (auto-registered)',
redirect_uris: o.redirect_uris, redirect_uris: [REDIRECT_URI],
scopes: ['openid', 'profile', 'email', 'groups'], scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [], allowed_groups: [],
}), }),
@@ -295,7 +242,7 @@ async function createClient(token, opts) {
const id = (data.results && data.results.client_id) || data.client_id; const id = (data.results && data.results.client_id) || data.client_id;
const secret = data.client_secret; const secret = data.client_secret;
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`); if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
log(`Created OAuth client ${o.name} (${id})`); log(`Created OAuth client ${CLIENT_NAME} (${id})`);
return { id, secret }; return { id, secret };
} }
@@ -314,185 +261,6 @@ async function rotateClient(token, id) {
return { id, secret: data.client_secret }; return { id, secret: data.client_secret };
} }
// ── 5. Seed the SSO directory with the stack's own resources ────────────────
// The Directory page (site → host → service hierarchy) starts empty even
// though this stack knows exactly what it deployed. Seed it: one site (the
// domain), one host (the box this stack runs on), and the two services
// (SSO Manager + proxy), then link the proxy's OAuth client under its
// service. Idempotent — existing slugs are left untouched, so operator
// edits (renames, metadata, extra resources) survive re-runs. Failures
// here only warn: the directory is a nicety, never worth failing a
// bring-up over (e.g. an older sso-manager image without /api/directory).
const DOMAIN = (sso.stack && sso.stack.ldapDomain) || '';
const ORG = sso.name || 'SSO Manager';
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
async function dirGet(token, path) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/directory-admin/${path} failed (${res.status})`);
return res.json();
}
async function dirPost(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
async function dirPut(token, path, body) {
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
method: 'PUT',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`PUT /api/directory-admin/${path} failed (${res.status}): ${text}`);
}
return res.json();
}
// The site the stack registers itself under. Also the default "Location
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
const SITE_NAME = (sso.stack && sso.stack.siteName) || 'local';
// Host facts, collected by setup.sh ON THE HOST (inside this container
// hostname/uname describe the container) and passed via the exec env. Same
// fields ldap-client/index.sh registers, so stack hosts and ldap-client-
// joined hosts carry identical metadata.
const HOST_FACTS = {
name: process.env.STACK_HOST_NAME || '',
ip: process.env.STACK_HOST_IP || '',
mac: process.env.STACK_HOST_MAC || '',
os: process.env.STACK_HOST_OS || '',
kernel: process.env.STACK_HOST_KERNEL || '',
};
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Create a resource unless its slug (or a legacy alternate from an earlier
// seed layout) already exists. On an existing resource, seed metadata keys
// it doesn't have yet are filled in — operator-set values always win and
// are never overwritten.
async function ensure(kind, name, slug, parentId, metadata, altSlugs) {
const slugs = [slug, ...(altSlugs || [])];
const found = resources.find((r) => slugs.includes(r.slug));
if (found) {
const have = found.metadata || {};
const missing = Object.entries(metadata || {})
.filter(([k, v]) => (have[k] === undefined || have[k] === '') && v !== '');
if (missing.length) {
const merged = { ...have };
for (const [k, v] of missing) merged[k] = v;
// metadata-only PUT: no kind/hostId in the body, so the route's
// parent validation and edge rewiring are not triggered.
await dirPut(token, `resources/${found.id}`, { metadata: merged });
found.metadata = merged;
log(` directory: ${kind} '${found.slug}' exists — filled ${missing.map(([k]) => k).join(', ')}`);
} else {
log(` directory: ${kind} '${found.slug}' exists — keeping`);
}
return found;
}
const body = { kind, name, slug, metadata: metadata || {} };
if (parentId) body.hostId = parentId; // POST creates the parent edge
const created = (await dirPost(token, 'resources', body)).results;
resources.push(created);
log(` directory: created ${kind} '${slug}'`);
return created;
}
// site_<name> / host_<name> slug convention matches ldap-client/index.sh.
// altSlugs grandfather in the layout the first seed release used.
const site = await ensure('site', SITE_NAME, `site_${slugify(SITE_NAME)}`, null,
{ isCurrentSite: true },
[slugify(DOMAIN || ORG)]);
const hostSlug = HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : 'stack-host';
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
subType: 'linux',
ip: HOST_FACTS.ip,
macAddress: HOST_FACTS.mac,
os: HOST_FACTS.os,
kernel: HOST_FACTS.kernel,
}, ['stack-host']);
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
address: `https://${SSO_HOST}`,
port: 3001,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'web',
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
address: `https://${PROXY_HOST}`,
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'web',
});
// OpenLDAP is independently consumed — Linux hosts authenticate against it
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
// (see the SSO's /integrations page) — so it gets its own entry. Advertise
// the operator-configured LDAPS hostname when set, else the SSO host.
// The bundled slapd's image/config live in sso-manager-node.
const LDAPS_HOST = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
await ensure('service', 'OpenLDAP Directory', 'openldap', host.id, {
address: `ldaps://${LDAPS_HOST}:636`,
port: 389,
externalPort: 636,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'openldap',
});
// Wildcard address: OpenResty fronts every host under the domain (same
// */** wildcard convention the proxy's Host records use). Its config lives
// in the proxy repo (ops/nginx_conf).
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
port: 443,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'openresty',
});
// Optional SSH jump host service.
let jumpSvc = null;
if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) {
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
subType: 'ssh',
});
}
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
await linkOauthClient(clientId, psvc, 'proxy');
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy // Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
// (which reads that file) can use them. Only the clientId/clientSecret lines // (which reads that file) can use them. Only the clientId/clientSecret lines
// are touched; the rest of the file (operator edits, comments) is preserved. // are touched; the rest of the file (operator edits, comments) is preserved.
@@ -523,148 +291,6 @@ function writeProxyCreds(id, secret) {
} }
} }
// ── 6. Optional: provision the SSH jump host ────────────────────────────────
// When CFG_JUMP_HOST_ENABLED=true, the jump host needs: a directory API token
// (to resolve which hosts a user may reach), an LDAP bind account that can
// WRITE the sshPublicKey attribute (it injects its own key on first use), and
// a config file it reads. We write /config/jump-secrets.js deriving LDAP/site
// from sso-secrets.js + a freshly minted API token. The bundled jump host
// binds as cn=admin (already able to write sshPublicKey) — hardened bare-metal
// deployments should use a scoped account + attribute ACL instead (see the
// jump-host README). Idempotent: skips if the file already has a real token.
const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '');
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
const JUMP_SECRETS = '/config/jump-secrets.js';
const JUMP_TOKEN_NAME = 'theta-jump-host';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: 'theta-env jump host (auto-registered)' }),
});
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
const data = await res.json();
const raw = data.token || (data.results && data.results.token) || data.raw_token;
if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`);
return raw;
}
// The generated file is "complete" only if it has BOTH a real directory API
// token AND an OIDC client id — an existing file from the pre-OIDC layout (a
// token but no oidc block) is regenerated so the web UI's SSO login works.
function jumpFileComplete() {
try {
const src = fs.readFileSync(JUMP_SECRETS, 'utf8');
const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src);
const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src);
return hasToken && hasOidc;
} catch (_) { return false; }
}
function writeJumpSecrets(apiToken, oidc, localAdminPass) {
const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict';
// Generated by theta-env bootstrap. The jump host reads this via
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
// sshPublicKey attribute (key injection); for a hardened deployment use a
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
module.exports = {
\tname: ${JSON.stringify(sso.name || 'SSO Manager')},
\tldap: {
\t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
\t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
\t\t// treats a non-empty tlsOptions as "use implicit TLS" regardless of the
\t\t// URL scheme -- pointed at the plain port, that means it opens a raw TLS
\t\t// handshake against a server expecting plaintext LDAP, which slapd just
\t\t// drops (logged as "connection lost", no BIND ever attempted). This bit
\t\t// jump-host silently: every SSH login failed with the generic
\t\t// "Permission denied" for any password, because getUser()/checkPassword()
\t\t// never even reached slapd.
\t\turl: 'ldaps://sso-manager:636',
\t\tbindDN: ${JSON.stringify(BIND_DN)},
\t\tbindPassword: ${JSON.stringify(ADMIN_PASS)},
\t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)},
\t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)},
\t\ttlsOptions: { rejectUnauthorized: false },
\t},
\tsso: {
\t\turl: 'http://sso-manager:3001',
\t\tapiToken: ${JSON.stringify(apiToken)},
\t},
\tssh: {
\t\tlistenPort: 2222,
\t\thostKeyPath: '/var/lib/jump-host/keys',
\t\tpasswordAuth: 'off',
\t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)},
\t},
\tweb: { port: 3002 },
\t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint /
\t// userinfoEndpoint use the internal docker-net address (server-to-server);
\t// authorizationEndpoint is the public SSO host (browser-facing).
\toidc: {
\t\tenabled: true,
\t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)},
\t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)},
\t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token',
\t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
\t\tclientId: ${JSON.stringify(oidc.id)},
\t\tclientSecret: ${JSON.stringify(oidc.secret)},
\t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)},
\t\tscopes: ['openid', 'profile', 'email', 'groups'],
\t\tgroupsClaim: 'groups',
\t\tusernameClaim: 'preferred_username',
\t},
\tauth: {
\t\tadminGroups: ['app_sso_admin'],
\t\tadminUsers: ['jumpadmin'],
\t\tlocalAdminPass: ${JSON.stringify(localAdminPass)},
\t},
\tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } },
\tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} },
};
`;
fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 });
}
// Returns the jump host's OAuth client id (so seedDirectory can link it under
// the SSH Jump Host service), whether or not this run actually wrote a fresh
// jump-secrets.js -- otherwise re-runs on an already-configured deployment
// never get a chance to self-heal a missing directory link (see the "no
// parent" bug this was written for).
async function provisionJumpHost(token) {
if (jumpFileComplete()) {
log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
const clients = await listClients(token);
const existing = clients.find((c) => c.name === JUMP_CLIENT_NAME);
return existing ? existing.client_id : null;
}
const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME);
// Mint (or reuse) the jump host's own OAuth client for web-UI SSO login.
const clients = await listClients(token);
let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME);
if (oidc && oidc.client_id) {
oidc = await rotateClient(token, oidc.client_id);
oidc = { id: oidc.id, secret: oidc.secret };
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-env jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
const localAdminPass = crypto.randomBytes(16).toString('hex');
writeJumpSecrets(apiToken, oidc, localAdminPass);
log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`);
log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`);
return oidc.id;
}
(async function main() { (async function main() {
try { try {
log(`Base DN: ${BASE_DN}`); log(`Base DN: ${BASE_DN}`);
@@ -674,7 +300,6 @@ async function provisionJumpHost(token) {
const list = await listClients(token); const list = await listClients(token);
// Find the proxy's client: by id if we have usable creds, else by name. // Find the proxy's client: by id if we have usable creds, else by name.
let resolvedClientId = '';
let client = null; let client = null;
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID); if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
if (!client) client = list.find((c) => c.name === CLIENT_NAME); if (!client) client = list.find((c) => c.name === CLIENT_NAME);
@@ -687,7 +312,6 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', EXISTING_ID); out('CLIENT_ID', EXISTING_ID);
out('CLIENT_SECRET', EXISTING_SECRET); out('CLIENT_SECRET', EXISTING_SECRET);
out('ALREADY_CONFIGURED', '1'); out('ALREADY_CONFIGURED', '1');
resolvedClientId = EXISTING_ID;
} else if (client) { } else if (client) {
// Client exists but the file has no recoverable secret for it — rotate // Client exists but the file has no recoverable secret for it — rotate
// so the proxy gets a fresh secret it can actually read, then write back. // so the proxy gets a fresh secret it can actually read, then write back.
@@ -697,7 +321,6 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', id); out('CLIENT_ID', id);
out('CLIENT_SECRET', secret); out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0'); out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
} else { } else {
// No client yet — create one and write the generated creds back. // No client yet — create one and write the generated creds back.
const { id, secret } = await createClient(token); const { id, secret } = await createClient(token);
@@ -705,45 +328,7 @@ async function provisionJumpHost(token) {
out('CLIENT_ID', id); out('CLIENT_ID', id);
out('CLIENT_SECRET', secret); out('CLIENT_SECRET', secret);
out('ALREADY_CONFIGURED', '0'); out('ALREADY_CONFIGURED', '0');
resolvedClientId = id;
} }
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
// fresh: writeProxyCreds rewrote the file out from under the cached
// `proxy` object. setup.sh's seed already put a placeholder version
// here; this replaces it with the complete file (operator edits +
// generated OAuth creds). Warn-only.
await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js'));
// Provision the jump host (mint token + write config) when enabled.
// Warn-only — never fail the whole bring-up over the optional service.
let jumpClientId = null;
if (JUMP_ENABLED) {
try {
jumpClientId = await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
// Mirror jump-secrets.js (just written by provisionJumpHost)
// into OpenBao so the jump host loads it from there at boot via
// @simpleworkjs/bao-conf. setup.sh's seed may have put a
// placeholder/stale version here; this replaces it with the
// complete file (LDAP bind, minted API token, OAuth client).
// Warn-only.
await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS));
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
}
// Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue.
try {
log('Seeding directory resources...');
await seedDirectory(token, resolvedClientId, jumpClientId);
} catch (e) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
log('Done.'); log('Done.');
process.exit(0); process.exit(0);
} catch (e) { } catch (e) {
@@ -1,26 +0,0 @@
# ldap-client config for the optional local jump-host test fixture
# (ldap-test-host service in docker-compose.yml, jump-host compose profile).
# Copy to ./config/ldap-test-host.vars and fill in the bind password from
# your own ./config/sso-secrets.js's `serviceAccountPass` (the
# cn=ldapclient,ou=people,<base> service account bootstrap/bootstrap.js
# creates specifically for this kind of 3rd-party/container LDAP bind).
#
# This is what lets ldap-test-host be a REAL SSSD+AuthorizedKeysCommand-joined
# downstream host, so jump-host's key-injection -> upstream-connect flow can
# be exercised end-to-end against something more than a container with a
# manually-dropped public key in authorized_keys.
export ldap_host="sso-manager"
export ldap_base_dn="dc=localtest,dc=me"
export ldap_bind_dn="cn=ldapclient,ou=People,$ldap_base_dn"
export ldap_bind_password="REPLACE_WITH_serviceAccountPass_FROM_sso-secrets.js"
# sso_url/sso_token deliberately left unset -- register the host + access
# group manually via the Directory admin API instead (index.sh's optional
# auto-registration also wants a parent site Resource to exist first).
# index.sh gates that block on `[[ -v sso_token ]]`, which is true even for
# an empty string, so leave these genuinely absent, not "".
export ldap_location="jumptest"
ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )
+2 -2
View File
@@ -5,8 +5,8 @@
// bootstrap writes the OAuth client clientId/clientSecret back into it; this // bootstrap writes the OAuth client clientId/clientSecret back into it; this
// file documents the shape for manual editing / reference. // file documents the shape for manual editing / reference.
// //
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets // The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh
// CONF_SECRETS to point at it). Never commit ./config/. // symlinks it to /app/conf/secrets.js). Never commit ./config/.
module.exports = { module.exports = {
oidc: { oidc: {
+2 -15
View File
@@ -4,8 +4,8 @@
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file // `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
// documents the shape for manual editing / reference. // documents the shape for manual editing / reference.
// //
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets // The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh symlinks
// CONF_SECRETS to point at it). The app ignores the extra stack/bootstrap/ // it to /app/conf/secrets.js). The app ignores the extra stack/bootstrap/
// serviceAccountPass keys (read by the orchestrator). Back this up off-host — // serviceAccountPass keys (read by the orchestrator). Back this up off-host —
// it holds all SSO secrets. Never commit ./config/. // it holds all SSO secrets. Never commit ./config/.
@@ -17,9 +17,6 @@ module.exports = {
bindPassword: 'CHANGE-ME', // slapd root + app bind password bindPassword: 'CHANGE-ME', // slapd root + app bind password
userBase: 'ou=people,dc=example,dc=com', userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com', groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: internal-only hostname
// shown on /integrations for direct LDAPS binds. Empty -> derive from issuer.
// ldapsPort: 636,
}, },
smtp: { // optional; leave host '' to skip smtp: { // optional; leave host '' to skip
host: '', port: 587, secure: false, host: '', port: 587, secure: false,
@@ -30,16 +27,6 @@ module.exports = {
jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret jwtSecret: 'CHANGE-ME', // signs all tokens — keep secret
token_lifetime: { access_token: 3600, refresh_token: 2592000 }, 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; read by setup.sh + bootstrap) ── // ── Orchestrator-only (ignored by the app; read by setup.sh + bootstrap) ──
stack: { stack: {
+13 -158
View File
@@ -13,12 +13,11 @@
# Config + secrets live in bind-mounted ./config/ (gitignored): # Config + secrets live in bind-mounted ./config/ (gitignored):
# ./config/sso-secrets.js — SSO app + orchestrator config # ./config/sso-secrets.js — SSO app + orchestrator config
# ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config # ./config/proxy-secrets.js — proxy OIDC/LDAP/auth config
# Each app's entrypoint points CONF_SECRETS at its file so @simpleworkjs/conf # Each app's entrypoint symlinks its file into /app/conf/secrets.js so
# (>= 1.2.0) reads it directly -- no app_* env is passed (app_* env would # @simpleworkjs/conf reads it. No app_* env is passed (app_* env would override
# override secrets.js), and no write access to /app/conf is needed. The # secrets.js). The sso-manager mounts ./config read-write so the bootstrap can
# sso-manager mounts ./config read-write so the bootstrap can write the # write the generated OAuth client creds back into proxy-secrets.js; the proxy
# generated OAuth client creds back into proxy-secrets.js; the proxy mounts # mounts it read-only.
# it read-only.
# #
# Compose only interpolates the port defaults below — there is no .env file. # Compose only interpolates the port defaults below — there is no .env file.
# First-run wiring (LDAP service account, first admin, OAuth client) is # First-run wiring (LDAP service account, first admin, OAuth client) is
@@ -30,22 +29,8 @@ services:
build: build:
context: ./sso-manager-node context: ./sso-manager-node
dockerfile: Dockerfile.openldap dockerfile: Dockerfile.openldap
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C sso-manager-node rev-parse --short HEAD).
GIT_COMMIT: ${SSO_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build (NOT
# the theta42 "proxy" app). Set CFG_HTTP_PROXY in setup.env; empty by
# default, so this is a no-op unless configured.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: sso-manager container_name: sso-manager
restart: unless-stopped restart: unless-stopped
depends_on:
- openbao
networks: [theta-net] networks: [theta-net]
ports: ports:
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so # SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
@@ -54,32 +39,18 @@ services:
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001" - "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself # LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping. # reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
# in sso-secrets.js) and do NOT forward 636 to the public internet.
- "${LDAPS_PORT:-636}:636" - "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS. # Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
environment: environment:
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from # Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf # volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app
# deep-merges secret/sso-manager/conf from OpenBao over it at boot # reads that are not part of its conf tree.
# (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other
# env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by
# setup.sh (policy sso-broker) — NOT the root token.
- NODE_ENV=production - NODE_ENV=production
- NODE_PORT=3001 - NODE_PORT=3001
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
# runtime. See the build args above for the same setting during build.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes: volumes:
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap # Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
# can write the generated OAuth client creds into proxy-secrets.js. The # can write the generated OAuth client creds into proxy-secrets.js. The
# entrypoint points CONF_SECRETS at /config/sso-secrets.js. # entrypoint symlinks /config/sso-secrets.js -> /app/conf/secrets.js.
- ./config:/config - ./config:/config
# Persist the LDAP database across container recreation. # Persist the LDAP database across container recreation.
- ldap-data:/var/lib/ldap - ldap-data:/var/lib/ldap
@@ -103,25 +74,12 @@ services:
build: build:
context: ./proxy context: ./proxy
dockerfile: Dockerfile dockerfile: Dockerfile
args:
# A submodule's .git is a pointer file, not a real repo — the image
# can't resolve its own commit hash from inside the build context.
# setup.sh sets this from the host, where the submodule resolves
# correctly (git -C proxy rev-parse --short HEAD).
GIT_COMMIT: ${PROXY_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: proxy container_name: proxy
restart: unless-stopped restart: unless-stopped
networks: [theta-net] networks: [theta-net]
depends_on: depends_on:
sso-manager: sso-manager:
condition: service_healthy condition: service_healthy
openbao:
condition: service_started
ports: ports:
- "${HTTP_PORT:-80}:80" - "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443" - "${HTTPS_PORT:-443}:443"
@@ -131,28 +89,14 @@ services:
# to lock it to localhost once the proxy fronts it under TLS. # to lock it to localhost once the proxy fronts it under TLS.
- "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000" - "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000"
environment: environment:
# oidc/ldap/auth config is loaded by @simpleworkjs/conf from # oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes),
# ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf # not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
# deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth
# clientSecret is consumed at require time, so bao-conf.init() runs
# BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env
# the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN
# (policy proxy — read only secret/proxy/conf).
- NODE_ENV=production - NODE_ENV=production
- NODE_PORT=3000 - NODE_PORT=3000
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's
# Encrypt, DNS providers) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes: volumes:
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy # Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
# only reads it; the sso-manager bootstrap writes the OAuth creds. The # only reads it; the sso-manager bootstrap writes the OAuth creds. The
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a # entrypoint symlinks /config/proxy-secrets.js -> /app/conf/secrets.js.
# fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is
# unreachable the app boots from this file instead.
- ./config:/config:ro - ./config:/config:ro
# Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local # Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local
# users, AND the auto-ssl Let's Encrypt certs survive container recreation. # users, AND the auto-ssl Let's Encrypt certs survive container recreation.
@@ -170,92 +114,6 @@ services:
retries: 3 retries: 3
start_period: 30s start_period: 30s
# Optional SSH jump host. Only started when the `jump-host` compose profile
# is active — setup.sh exports COMPOSE_PROFILES=jump-host when
# CFG_JUMP_HOST_ENABLED=true. Authenticates users against the SSO's OpenLDAP,
# resolves reachable hosts from the directory API, and bridges SSH through.
jump-host:
profiles: ["jump-host"]
build:
context: ./jump-host
dockerfile: Dockerfile
args:
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
# Optional upstream HTTP(S) proxy for npm/apt during the build. See
# the sso-manager service above for details.
HTTP_PROXY: ${CFG_HTTP_PROXY:-}
HTTPS_PROXY: ${CFG_HTTPS_PROXY:-}
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: jump-host
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
ports:
- "${JUMP_SSH_PORT:-2222}:2222" # SSH front door
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
environment:
- NODE_ENV=production
# Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js,
# then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from
# OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN
# (policy jump-host — read only secret/jump-host/conf).
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (the directory API
# client) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
- jump-data:/var/lib/jump-host # generated host keys persist here
- jump-redis-data:/data # Redis (sessions, OAuth state, API tokens) persists here
# A real, LDAP-joined (SSSD + AuthorizedKeysCommand) downstream host for
# testing jump-host's actual key-injection -> upstream-connect flow --
# a container with a manually-dropped public key in authorized_keys never
# exercises the LDAP-key-serving path a real production host does. Built
# from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars
# for setup notes. Same jump-host profile, so
# `docker compose --profile jump-host up` brings up jump-host and a host it
# can actually reach together.
ldap-test-host:
profiles: ["jump-host"]
build:
context: ./ldap-client
dockerfile: Dockerfile
container_name: ldap-test-host
hostname: ldap-test-host
restart: unless-stopped
networks: [theta-net]
depends_on:
sso-manager:
condition: service_healthy
privileged: false
volumes:
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
openbao:
image: quay.io/openbao/openbao:latest
container_name: openbao
restart: unless-stopped
cap_add:
- IPC_LOCK
command: server -config=/vault/config/openbao.hcl
environment:
- BAO_ADDR=http://127.0.0.1:8200
ports:
- "8080:8200"
volumes:
- ./config/openbao.hcl:/vault/config/openbao.hcl:ro
- openbao-data:/vault/data
networks:
- theta-net
networks: networks:
theta-net: theta-net:
driver: bridge driver: bridge
@@ -266,7 +124,4 @@ volumes:
sso-data: sso-data:
proxy-data: proxy-data:
proxy-cache: proxy-cache:
proxy-logs: proxy-logs:
jump-data:
jump-redis-data:
openbao-data:
+4 -39
View File
@@ -1,44 +1,9 @@
title: theta-env title: theta-env
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses
url: "https://theta42.github.io" theme: jekyll-theme-cayman
baseurl: "/theta-env" show_downloads: true
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github: github:
repository_url: https://github.com/theta42/theta-env repository_url: https://github.com/theta42/theta-env
zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz
repository_name: theta42/theta-env repository_name: theta42/theta-env
nav:
- title: Home
page: /
icon: fa-house
- title: Quickstart
page: /quickstart.html
icon: fa-rocket
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Secrets
page: /secrets.html
icon: fa-key
- title: Standalone
page: /standalone.html
icon: fa-puzzle-piece
- title: Changelog
url: https://github.com/theta42/theta-env/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
-82
View File
@@ -1,82 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+13 -27
View File
@@ -1,7 +1,6 @@
--- ---
layout: default layout: default
title: Architecture title: Architecture
description: How theta-env composes the SSO Manager and proxy submodules — the OIDC/LDAP wiring setup.sh generates from one domain.
--- ---
# Architecture # Architecture
@@ -31,7 +30,7 @@ fetches all three in one step; `git submodule update --remote` bumps them.
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ your browser / apps / direct LDAP clients │ │ your browser / apps / legacy LDAP clients │
└───────────────┬──────────────────────────────┘ └───────────────┬──────────────────────────────┘
│ https (:443) ldaps (:636) │ https (:443) ldaps (:636)
┌─────────▼─────────┐ ┌─────────▼─────────┐
@@ -102,41 +101,28 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret` read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret`
matching an existing client, they are kept; if the client exists but the file matching an existing client, they are kept; if the client exists but the file
has no usable secret, the secret is rotated and written back. has no usable secret, the secret is rotated and written back.
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint points 6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint symlinks
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf` `./config/proxy-secrets.js` to `/app/conf/secrets.js`, so `@simpleworkjs/conf`
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file. (≥1.1.0) reads the OAuth creds + LDAP bind creds from the file.
7. **Register `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy**
`setup.sh` runs a short script inside the proxy container that calls its
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
than the proxy's own HTTP API, since no authenticated session exists yet at
this point in the run. The proxy routes every hostname purely off a Host
record (`ops/nginx_conf/proxy.conf` has no default/self route), so without
this step neither URL resolves to anything. `<SSO_HOST>` targets
`sso-manager:3001` (the Docker service), `<PROXY_HOST>` targets
`127.0.0.1:3000` (the proxy's own management app, same container). Both
are created with `sso_enabled: false` — each app already gates its own
login, and SSO-gating the SSO's own login page would be circular. Skips a
host that already exists, so re-running `setup.sh` is a no-op here.
`setup.sh` then prints the first-admin login + the public URLs. `setup.sh` then prints the first-admin login + the public URLs.
### How config reaches the apps (no `.env`) ### How config reaches the apps (no `.env`)
All config and secrets live in `./config/` (gitignored, bind-mounted). Each All config and secrets live in `./config/` (gitignored, bind-mounted). Each
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0) entrypoint symlinks its file to `/app/conf/secrets.js` early, before the app
at its file early, before the app starts: starts:
``` ```
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW) ./config/sso-secrets.js -> sso-manager:/app/conf/secrets.js (./config RW)
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO) ./config/proxy-secrets.js -> proxy:/app/conf/secrets.js (./config RO)
``` ```
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_* `@simpleworkjs/conf` loads `conf/base.js → <env>.js → conf/secrets.js → app_*
env`, where **env beats the secrets file**. So compose passes **no `app_*` env env`, where **env beats `secrets.js`**. So compose passes **no `app_*` env vars**
vars** (only `NODE_ENV`, `NODE_PORT`) — that makes the secrets file (only `NODE_ENV`, `NODE_PORT`) — that makes `secrets.js` authoritative. The SSO
authoritative. The SSO entrypoint reads the few values it needs at startup entrypoint reads the few values it needs at startup (LDAP base DN, admin
(LDAP base DN, admin password, JWT secret, cert CN) from `sso-secrets.js` via password, JWT secret, cert CN) from `secrets.js` via an in-container `node` call.
an in-container `node` call.
### Why not `require` the SSO's internal models? ### Why not `require` the SSO's internal models?
-116
View File
@@ -1,116 +0,0 @@
/* theta42 docs site — shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
-51
View File
@@ -1,51 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

+101 -60
View File
@@ -1,78 +1,119 @@
--- ---
layout: default layout: default
title: Home title: Home
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. Wires together a self-hosted identity provider and a reverse proxy with one setup.sh.
--- ---
# theta-env # theta-env
The whole theta42 identity + access stack in one repo, brought up with a A single repo that runs the whole theta42 identity + access stack
single command — for home labs and small businesses. [SSO Manager](https://github.com/theta42/sso-manager-node) (OIDC provider + LDAP)
and the [theta42/proxy](https://github.com/theta42/proxy) (OIDC-protected reverse
proxy) — together, with **one command**, for home labs and small businesses.
It wires together two projects that already work on their own — It exists for people whose needs are met by these two projects and who want to
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider + run them "very simply." Each project still works **standalone**; this repo just
LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an wires them together and automates the first-run glue.
OIDC-protected reverse proxy that can also look users up directly in LDAP) —
and automates the fiddly part: registering the proxy as an OIDC client of the
SSO and pointing it at the right LDAP directory, with hostnames and secrets
generated from one `setup.env`. An optional third component, the
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
access to your machines through one public entry point.
## Screenshots ---
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run: ## Quick start
<a href="images/sso-dashboard.png" target="_blank"><img src="images/sso-dashboard.png" alt="SSO Manager dashboard" width="49%"></a>
<a href="images/proxy-hosts.png" target="_blank"><img src="images/proxy-hosts.png" alt="Proxy host list" width="49%"></a>
<a href="images/jump-dashboard.png" target="_blank"><img src="images/jump-dashboard.png" alt="Jump Host dashboard" width="49%"></a>
*(click either screenshot to view full size)*
## Why this over running them separately
Each project works standalone, but they only become useful together once the
proxy is registered as an OIDC client of the SSO *and* pointed at the SSO's
LDAP directory — and the domain has to match across half a dozen config
fields, or logins silently fail. Doing that by hand is fiddly. `setup.sh`
asks for your domain once, generates both apps' config with it filled in
everywhere, registers the proxy as an OIDC client automatically, and
snapshots state before every rebuild.
## What you get
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
and OAuth clients.
- **Proxy** — add the hosts you want to protect with OIDC login.
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
LDAP-native apps authenticate against the same directory.
- **SSH Jump Host** *(optional)*`ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
or an interactive picker; access is driven by directory group membership, with
a web UI for audit + metrics. Enable with `CFG_JUMP_HOST_ENABLED=true`.
- **Self-service API tokens** in both apps' UIs, for scripting/CI without a
browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
## Get it
```bash ```bash
git clone --recursive https://github.com/theta42/theta-env.git git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env cd theta-env
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
./setup.sh ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
``` ```
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
any time to converge the stack to `./config/`. For the full config reference, time to converge the stack to `./config/`.
architecture, and running each project standalone, see the
**[GitHub repository](https://github.com/theta42/theta-env)**.
## Related projects See the [Quickstart Guide](quickstart.html) for a walkthrough of `./config/` and
what `setup.sh` does, [Architecture](architecture.html) for how the pieces fit
together, and [Standalone](standalone.html) for running each project on its own.
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC ---
provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this ## What you get
stack runs in front of it.
- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump - **SSO Manager** at `https://<SSO_HOST>` — log in as your first admin to manage
host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`). users, groups, and OAuth clients. Fronted by the proxy under TLS.
- **Proxy** at `https://<PROXY_HOST>` — add the Host records you want to protect
with OIDC login.
- **LDAPS** at `ldaps://<host>:636` — legacy apps can bind directly (admin or
the read-only `cn=ldapclient` service account the bootstrap creates).
- **API tokens** — both apps let any logged-in user mint self-service personal
access tokens (`Authorization: Bearer sso_…` / `prx_…`) to drive the management
API from scripts/CI without a browser session. A token authenticates as its
creator (carrying their permissions); mint/rotate/revoke under **API Tokens**
in each UI. See each submodule's DEPLOYMENT for the details.
---
## The `./config/` values you must set
All config and secrets live in `./config/sso-secrets.js` +
`./config/proxy-secrets.js` (gitignored), generated by the first `./setup.sh`.
There is **no `.env`**. Set at least these in `./config/sso-secrets.js`:
| Key (in `sso-secrets.js`) | What it is |
|-----|------------|
| `stack.ldapBaseDn` | Directory base, e.g. `dc=lab,dc=local`. |
| `ldap.bindPassword` | LDAP root password (generated). **Back it up.** |
| `oauth.jwtSecret` | Signs the SSO's tokens (generated). **Back it up.** |
| `stack.ssoHost` | Public hostname the proxy serves the SSO UI at. |
| `stack.proxyHost` | Public hostname the proxy serves its own mgmt UI at. |
| `bootstrap.adminUid` / `bootstrap.adminPass` | Your first admin login. |
See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy
OIDC/LDAP/auth, …).
---
## Architecture
```
┌──────────────────────────────────────────────┐
│ your browser / apps │
└───────────────┬──────────────────────────────┘
│ https
┌─────────▼─────────┐
│ proxy │ OpenResty :80/:443/:4443
│ (OIDC + LDAP) │ mgmt app :3000 (localhost)
└─────────┬─────────┘ bundled redis
┌─────────────┼──────────────────────┐
│ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo
▼ ▼ │
┌──────────────────────────┐ │
│ sso-manager │◄────────────────┘
│ OIDC provider + OpenLDAP │ bundled redis
│ web UI :3001 (localhost) │
│ ldaps :636 (LAN clients) │
└───────────────────────────┘
```
The proxy is **both** an OIDC client of the SSO (for login) **and** a direct LDAP
client (for user lookups). See [Architecture](architecture.html) for the full
diagram + the first-run bootstrap flow.
---
## Documentation
- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `setup.sh`.
- [Architecture](architecture.html) — the 3-repo + submodule + 2-container
design, and how the bootstrap wires the proxy into a fresh SSO.
- [Standalone](standalone.html) — running SSO Manager or the proxy on its own.
---
## Community
- [GitHub Repository](https://github.com/theta42/theta-env)
- [Issue Tracker](https://github.com/theta42/theta-env/issues)
---
## License
MIT License — see the repository for details.
+6 -16
View File
@@ -1,7 +1,6 @@
--- ---
layout: default layout: default
title: Quickstart title: Quickstart
description: Step-by-step first run for theta-env — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
--- ---
# Quickstart Guide # Quickstart Guide
@@ -43,26 +42,20 @@ git submodule update --init --recursive
```bash ```bash
cp setup.env.example setup.env cp setup.env.example setup.env
$EDITOR setup.env # set CFG_DOMAIN to your domain $EDITOR setup.env # set CFG_BASE_DN to your domain, as a base DN
``` ```
Your domain is entered **once**, as a plain DNS domain. The SSO/proxy Your domain is entered **once**, as the LDAP base DN. The SSO/proxy hostnames
hostnames default to `sso.<domain>` / `proxy.<domain>`, and the LDAP base DN default to `sso.<domain>` / `proxy.<domain>`, derived from it, so for most setups
is built from it (any number of labels works — a domain like `CFG_BASE_DN` is the only value you set:
`myhost.duckdns.org` becomes `dc=myhost,dc=duckdns,dc=org`), so for most
setups `CFG_DOMAIN` is the only value you set:
| `setup.env` key | Example | Notes | | `setup.env` key | Example | Notes |
|-----|---------|-------| |-----|---------|-------|
| `CFG_DOMAIN` | `lab.local` | your domain — **required** | | `CFG_BASE_DN` | `dc=lab,dc=local` | your directory base — **required** |
| `CFG_SSO_HOST` | `sso.lab.local` | optional, defaults to `sso.<domain>` | | `CFG_SSO_HOST` | `sso.lab.local` | optional, defaults to `sso.<domain>` |
| `CFG_PROXY_HOST` | `proxy.lab.local` | optional, defaults to `proxy.<domain>` | | `CFG_PROXY_HOST` | `proxy.lab.local` | optional, defaults to `proxy.<domain>` |
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` | | `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional | | `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
| `CFG_JUMP_HOST_ENABLED` | `true` | optional: bring up the [SSH jump host](https://theta42.github.io/jump-host/) (default off) |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` |
| `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) |
`setup.env` is used **only on the first run** to generate `./config/`; after `setup.env` is used **only on the first run** to generate `./config/`; after
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets
@@ -96,10 +89,7 @@ What happens:
service account, your first admin, and the proxy's OAuth client, and writes service account, your first admin, and the proxy's OAuth client, and writes
the generated client id + secret into `./config/proxy-secrets.js`. the generated client id + secret into `./config/proxy-secrets.js`.
4. Builds + starts **proxy**, waits for `/health`. 4. Builds + starts **proxy**, waits for `/health`.
5. Registers `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy — 5. Prints your first-admin login + the public URLs.
every hostname the proxy serves, including its own UI and the SSO's,
needs one of these or it 404s. Idempotent.
6. Prints your first-admin login + the public URLs.
The first run builds two Docker images (a few minutes). Subsequent runs are The first run builds two Docker images (a few minutes). Subsequent runs are
fast. fast.
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
-195
View File
@@ -1,195 +0,0 @@
---
layout: default
title: Secrets (OpenBao)
description: theta-env's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies.
---
# Secrets — OpenBao as the central store
theta-env keeps **every secret in one place: [OpenBao](https://openbao.org/)**
(a Vault-community fork), running on the `theta-net` docker network at
`http://openbao:8200`. The three apps (SSO Manager, proxy, jump host) load
their boot secrets from it; end users get personal per-user secret storage
through the SSO UI; and external apps get scoped, self-contained access to
their own namespace.
This page is the operator reference. For the package API, see
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/).
## Why a central store
Before this, secret handling was partial and inconsistent: only the SSO read
one path from OpenBao; the proxy and jump host read bind-mounted
`./config/*-secrets.js` files; the bootstrap wrote generated OAuth creds to
those files on disk; and the SSO `/api/vault` UI was an ungated, broken
pass-through. Centralising on OpenBao gives every app the same fail-soft load
path, makes per-user secret storage possible, and lets external apps get
least-privilege access without anyone handing them the root token.
## The load path (every app)
1. `@simpleworkjs/conf` **synchronously** loads the bind-mounted
`./config/<app>-secrets.js` at require time — the file is the operator-edit
layer and the fail-soft fallback.
2. `@simpleworkjs/bao-conf`'s `init({ path: '<app>', conf })` **deep-merges**
`secret/data/<app>/conf` from OpenBao over the live `conf` object. It is
**fail-soft**: if OpenBao is unreachable or the path is absent, boot
continues with the file-loaded config.
3. A few secrets are **captured at require time** (notably the OIDC
`clientSecret`, consumed inside `createOidcClient` during
`require('../models')`). So `init()` must resolve *before* that
`require()`. Each app's `bin/www` handles this:
- **proxy** — defers `require('../app')` (which transitively loads models)
behind `bao-conf.init()`.
- **jump host** — gates the explicit `require('../models')` behind
`bao-conf.init()`.
- **SSO** — swaps the old `conf_manager.init()` call (same position in its
existing `.then()` boot chain) for `bao-conf.init()`; nothing in the SSO
captures a secret at require time, so no reordering was needed.
`VAULT_TOKEN` (a scoped per-app token, **not** the root token) and
`VAULT_ADDR=http://openbao:8200` are passed to each container via
`docker-compose.yml`. The `./config/*-secrets.js` mounts stay as the fallback.
## Policies, token role, and tokens
`setup.sh` creates the ACL policies and mints the per-app tokens
(idempotently — re-running keeps existing tokens and re-mints only expired
ones). The root token stays in `.env` for setup/maintenance **only** and is
never passed to a service container.
| Policy | Capabilities | Held by |
|---|---|---|
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`; `update` on `auth/token/create/sso-broker`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
| `user-<uid>` | read/write `secret/users/<uid>/*` | per-user tokens (minted lazily by the broker) |
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
**Token role `sso-broker`**`allowed_policies=sso-admin`,
`allowed_policies_glob=user-*,app-*`, orphan, renewable, `token_period=24h`.
The SSO mints per-user, per-admin, and per-app tokens *through* this role at
runtime, so it never needs the root token to issue scoped access.
The three per-app tokens (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`) are minted orphan + renewable and stored in `./.env` by
`setup.sh`. They use OpenBao's default service-token TTL; if one expires,
re-run `./setup.sh` and the `ensure_token` helper re-mints it (the old one
expires on its own). Automated renewal is a planned follow-up, not yet built.
## Seeding
`setup.sh` seeds, on first run only (skipped if the path already exists):
- `secret/sso-manager/conf` — from `./config/sso-secrets.js` (operator-set
LDAP/SMTP/`jwtSecret`; the SSO has no bootstrap-generated creds, so the file
is the complete source of truth).
- `secret/proxy/conf` — from `./config/proxy-secrets.js` (placeholder OAuth
creds at this point).
- `secret/jump-host/conf` — from `./config/jump-secrets.js` after the bootstrap
writes it.
The **bootstrap** (`bootstrap/bootstrap.js`) then generates the real OAuth
client credentials and writes the *complete* `proxy-secrets.js` and
`jump-secrets.js` objects into `secret/proxy/conf` and `secret/jump-host/conf`
(POST, replacing the placeholder seed). After the first run, OpenBao is
authoritative; the `./config/*-secrets.js` files are operator-edit seed
artifacts and the fail-soft fallback.
## End-user personal secrets
Every logged-in user has a personal namespace `secret/users/<uid>/*`, reached
through the SSO UI at **Vault → My Secrets**. The SSO mints a `user-<uid>`
token on first access (cached in Redis for the token's lifetime) and proxies
`/api/vault` to OpenBao with **that** token injected server-side — the
client's SSO session token never reaches OpenBao.
- **Non-admins** see only their own namespace; the UI fixes the path prefix
to `users/<uid>/`. They can list, read, write, and delete secrets there.
- **Admins** (`app_sso_admin` / `app_super_admin`) get free-form access across
all of `secret/` plus an **Apps** tab (see below).
Scoping is enforced at **two** layers: the SSO's `scopeGuard` rejects any path
outside the subject's prefix with a 403 (defense-in-depth), and the token's
own OpenBao policy enforces the same at the API layer.
## External apps
An external (non-theta42) app gets scoped access to its own namespace,
`secret/apps/<name>/*`, via a token an admin mints once from the SSO UI's
**Vault → Apps** tab. The token is shown **once** (copy it immediately; it is
not stored retrievably) and confined by an `app-<name>` policy.
Convention:
- `secret/apps/<name>/conf` for config-style secrets, `secret/apps/<name>/*`
for arbitrary keys.
- The app authenticates with the header `X-Vault-Token: <minted token>`
against `http://<openbao-host>:8200/v1/secret/data/apps/<name>/...`.
Non-Node consumers (curl):
```bash
VAULT_ADDR=http://openbao:8200 # or your external-facing openbao address
# Write
curl -X POST "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" -H "Content-Type: application/json" \
-d '{"data":{"db_password":"..."}}'
# Read
curl -s "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" | jq .data.data
```
Node consumers can use [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
directly:
```js
const baoConf = require('@simpleworkjs/bao-conf');
const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-service/conf
await baoConf.set('apps/my-service/conf', { db_password: '...' });
```
## Operator rotation
If a secret is exposed (or just on a routine schedule), rotate it at the
**provider** first (the LDAP server, the SMTP host, the OAuth `jwtSecret`,
etc.), then update OpenBao:
```bash
# Read the current sso-manager conf
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf
# Write a new value (KV-v2 POST replaces the data; merge carefully)
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf \
ldap.bindPassword='<new>' smtp.password='<new>' oauth.jwtSecret='<new>'
```
Then restart the affected app so `bao-conf.init()` re-reads it
(`docker compose restart sso-manager`). Call-time readers pick up the change
on next read; require-time captures (OIDC `clientSecret`) need the restart.
> The SSO admin **Configuration** UI (`/api/conf`) writes `secret/sso-manager/conf`
> and updates the live conf immediately, so SMTP/discovery/oauth edits made
> there don't need a manual `bao kv put`.
## Backups
The OpenBao data volume `openbao-data` holds every secret. Back it up with the
rest of the stack (see the README's *Backups and restore* section). The
`./config/*-secrets.js` files are **not** a complete secret backup once OpenBao
is authoritative — they're the first-run seed and the fallback. A full disaster
recovery restores both the `openbao-data` volume (the authoritative store) and
`./config/` (the seed/fallback), then runs `./setup.sh` to unseal OpenBao and
re-mint the per-app tokens.
## What's not in scope yet
- **Renewal automation** — per-app/user tokens use OpenBao's default TTL and
are re-minted by `setup.sh` on expiry; a periodic renewal worker is a
follow-up.
- **History scrubbing** — if a secret was committed to git, rotating it is the
fix; scrubbing it from git history (BFG / `git filter-repo`) is a separate,
git-destructive operation you can opt into.
- **Per-app secrets beyond boot config** (e.g. the proxy's DNS-provider creds,
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
boot-critical `*-secrets.js` contents moved in this phase.
+8 -9
View File
@@ -1,7 +1,6 @@
--- ---
layout: default layout: default
title: Standalone title: Standalone
description: Running the SSO Manager or the proxy on their own, without theta-env's orchestration.
--- ---
# Running each project standalone # Running each project standalone
@@ -25,18 +24,18 @@ mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build docker compose up -d --build
``` ```
The entrypoint points the `CONF_SECRETS` env var at `config/sso-secrets.js` so The entrypoint symlinks `config/sso-secrets.js` to `nodejs/conf/secrets.js` so
`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and `@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and
the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass
**no `app_*` env** — env beats the secrets file, so `app_*` would silently **no `app_*` env** — env beats `secrets.js`, so `app_*` would silently override
override your file. your file.
- Web UI: `http://localhost:3001` - Web UI: `http://localhost:3001`
- Health: `http://localhost:3001/health` - Health: `http://localhost:3001/health`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` - OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAPS: `ldaps://<host>:636` - LDAPS: `ldaps://<host>:636`
Requires `@simpleworkjs/conf` >= 1.2.0. Full reference: Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html). [SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal ### Bare metal
@@ -62,11 +61,11 @@ mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build docker compose up -d --build
``` ```
The entrypoint points the `CONF_SECRETS` env var at `config/proxy-secrets.js` The entrypoint symlinks `config/proxy-secrets.js` to `nodejs/conf/secrets.js` so
so `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints + `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and `clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and
`auth` (admin groups/users). Pass **no `app_*` env** — env beats the secrets `auth` (admin groups/users). Pass **no `app_*` env** — env beats `secrets.js`,
file, so `app_*` would silently override your file. so `app_*` would silently override your file.
- Proxy (public, auto-SSL): `https://<host>/` - Proxy (public, auto-SSL): `https://<host>/`
- Mgmt UI / API: `http://127.0.0.1:3000/` - Mgmt UI / API: `http://127.0.0.1:3000/`
Submodule jump-host deleted from db3333e26d
Submodule ldap-client deleted from 31d8fa1229
-1
View File
@@ -1 +0,0 @@
https://github.com/theta42/theta-env/pull/75
+1 -1
Submodule proxy updated: 4aa994121a...3df7d8c5cb
+11 -70
View File
@@ -7,72 +7,29 @@
# (edit them directly; setup.env is ignored on later runs). # (edit them directly; setup.env is ignored on later runs).
# #
# cp setup.env.example setup.env # cp setup.env.example setup.env
# $EDITOR setup.env # set CFG_DOMAIN below to your domain # $EDITOR setup.env # set CFG_BASE_DN below to your domain
# ./setup.sh # generates ./config/ and builds the stack # ./setup.sh # generates ./config/ and builds the stack
# #
# Copying this file to setup.env (gitignored) keeps your domain out of git. # Copying this file to setup.env (gitignored) keeps your domain out of git.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Your domain. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED. Everything else # Your domain, as an LDAP base DN. THIS IS THE ONE PLACE THE DOMAIN IS ENTERED.
# derives from it: the SSO/proxy hostnames default to sso.<domain> / # Everything else derives from it: the SSO/proxy hostnames default to
# proxy.<domain>, and the LDAP base DN is built from it (example.com becomes # sso.<domain> / proxy.<domain>, and the LDAP DNs are cn=admin,<dn>,
# dc=example,dc=com; a 3-label domain like myhost.duckdns.org becomes # ou=people,<dn>, ou=groups,<dn>. Required — setup.sh refuses to run without it.
# dc=myhost,dc=duckdns,dc=org — any number of labels works). Required — CFG_BASE_DN=dc=example,dc=com
# setup.sh refuses to run without it.
CFG_DOMAIN=example.com
# Site name for the SSO directory — the root node this stack registers itself
# under on the Directory page, and the default "Location (Site)" that Linux
# hosts joined via ldap-client attach to (parent slug: site_<name>).
# Optional — defaults to "local".
#CFG_SITE_NAME=local
# Public hostnames. Optional — default to sso.<domain> / proxy.<domain> derived # Public hostnames. Optional — default to sso.<domain> / proxy.<domain> derived
# from CFG_DOMAIN above. Uncomment and set only if your hostnames differ # from CFG_BASE_DN above. Uncomment and set only if your hostnames differ
# (e.g. a different subdomain, or the domain isn't the bare apex): # (e.g. a different subdomain, or the domain isn't the bare apex):
#CFG_SSO_HOST=sso.example.com #CFG_SSO_HOST=sso.example.com
#CFG_PROXY_HOST=proxy.example.com #CFG_PROXY_HOST=proxy.example.com
# ── Optional SSH jump host ───────────────────────────────────────────────────
# Enable the theta42/jump-host component: a public SSH jump host that
# authenticates users against the directory and bridges them to downstream
# hosts (ssh uid_-_target@jump, or an interactive picker). Off by default.
# When true, setup.sh clones/builds the jump-host submodule, the bootstrap
# mints its directory API token + writes ./config/jump-secrets.js, and it's
# registered in the proxy + directory. See jump-host's README for the LDAP
# write-ACL note (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST_ENABLED=false
#CFG_JUMP_HOST=jump.example.com # defaults to jump.<domain>
#JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default)
# Advanced: override the derived LDAP base DN directly (e.g. to namespace
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
#CFG_BASE_DN=dc=example,dc=com
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
# For an isolated/offline/corporate-network test host that only reaches the
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
# Wired into every service's docker build (npm/apt) AND its running container
# (SMTP, ACME/Let's Encrypt, DNS provider calls, the jump-host directory API
# client). Leave unset to disable (the default); CFG_HTTPS_PROXY falls back to
# CFG_HTTP_PROXY if unset, and CFG_NO_PROXY defaults to covering the stack's
# own internal service names so container-to-container traffic never goes
# through the proxy.
#CFG_HTTP_PROXY=http://proxy.example.com:3128
#CFG_HTTPS_PROXY=http://proxy.example.com:3128
#CFG_NO_PROXY=localhost,127.0.0.1,sso-manager,proxy,jump-host
# Optional — sensible defaults if left blank: # Optional — sensible defaults if left blank:
#CFG_ORG=SSO Manager # app display name + outbound email org #CFG_ORG=SSO Manager # app display name + outbound email org
#CFG_ADMIN_UID=admin # initial SSO admin username #CFG_ADMIN_UID=admin # initial SSO admin username
#CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost> #CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost>
#CFG_LDAP_CERT_CN= # LDAP TLS cert CN; empty -> defaults to the domain #CFG_LDAP_CERT_CN= # LDAP TLS cert CN; empty -> defaults to the domain
#
# Hostname advertised on the SSO /integrations page for direct LDAPS binds.
# Leave blank to derive it from the public SSO host (same as oauth.issuer).
# Recommended: set an internal-only name like 'ldap.internal.example.com' or
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
#CFG_LDAPS_HOST=
# Optional SMTP (outbound email from the SSO app). Leave blank to disable: # Optional SMTP (outbound email from the SSO app). Leave blank to disable:
#CFG_SMTP_HOST=smtp.example.com #CFG_SMTP_HOST=smtp.example.com
@@ -82,23 +39,7 @@ CFG_DOMAIN=example.com
#CFG_SMTP_FROM=SSO Manager <noreply@example.com> #CFG_SMTP_FROM=SSO Manager <noreply@example.com>
# ── DO NOT put secrets here ────────────────────────────────────────────────── # ── DO NOT put secrets here ──────────────────────────────────────────────────
# The LDAP admin password, JWT secret, admin password, LDAP service-account # The LDAP admin password, JWT secret, admin password, and LDAP service-account
# password, and the proxy's local admin password are all GENERATED (random) # password are GENERATED (random) into ./config/sso-secrets.js on first run.
# into ./config/sso-secrets.js + ./config/proxy-secrets.js on first run. # Change them later by editing ./config/sso-secrets.js directly. Do NOT set
# Change them later by editing those files directly (the proxy's local admin # CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS here.
# password is the exception — see ./config/proxy-secrets.js's auth.localAdminPass
# comment for how to actually change it after the account exists). Do NOT set
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
# CFG_PROXY_ADMIN_PASS here.
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
# If deploying this stack across multiple physical sites to provide local HA
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
# This requires assigning a unique ID to each site and listing the LDAPS URLs
# of all OTHER sites in the cluster.
#
# Each site MUST have a unique LDAP_SERVER_ID (e.g. 1, 2, 3).
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
# Example for Site 1:
#LDAP_SERVER_ID=1
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
+34 -527
View File
@@ -3,36 +3,26 @@
# theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack. # theta-env setup — one-command bring-up of the unified SSO Manager + Proxy stack.
# #
# git clone --recursive <theta-env> && cd theta-env # git clone --recursive <theta-env> && cd theta-env
# cp setup.env.example setup.env # set CFG_DOMAIN to your domain (once) # cp setup.env.example setup.env # set CFG_BASE_DN to your domain (once)
# ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts # ./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
# ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched) # ./setup.sh # later runs: rebuilds + bootstraps + starts (config left untouched)
# #
# Idempotent: safe to re-run. It pulls its own latest version, updates the two # Idempotent: safe to re-run. It manages config in a bind-mounted ./config/
# submodules, manages config in a bind-mounted ./config/ directory # directory (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots
# (sso-secrets.js + proxy-secrets.js — NO .env / proxy.env), snapshots state # state before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# before rebuild, (re)starts the SSO Manager, runs the bootstrap (which
# converges the LDAP service account / first admin / OAuth client to the ./config # converges the LDAP service account / first admin / OAuth client to the ./config
# values and writes the generated OAuth client creds into proxy-secrets.js), # 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 # then starts the proxy.
# 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: # What it does, in order:
# 0. Pull theta-env'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 # 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 # (so each run builds the newest sso-manager-node + proxy). Skip with
# SKIP_SUBMODULE_UPDATE=1. # SKIP_SUBMODULE_UPDATE=1.
# 2. ensure_config: create ./config/sso-secrets.js + proxy-secrets.js if # 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 # 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 # (the one place the domain is entered, as the LDAP base DN) and both
# base DN is derived from it) and both files are generated with that # files are generated with that domain filled in everywhere + random
# domain filled in everywhere + random secrets, then the run proceeds to # secrets, then the run proceeds to build (no edit-and-re-run step). On
# build (no edit-and-re-run step). On
# an existing deployment with .env/proxy.env, the secrets are migrated # an existing deployment with .env/proxy.env, the secrets are migrated
# (preserved) into ./config. If ./config already exists it is left # (preserved) into ./config. If ./config already exists it is left
# untouched (the operator owns it; setup.env is ignored). # untouched (the operator owns it; setup.env is ignored).
@@ -43,17 +33,9 @@
# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js # 5. docker compose exec sso-manager node /bootstrap/bootstrap.js
# -> creates/updates the LDAP service account, first admin, OAuth client; # -> creates/updates the LDAP service account, first admin, OAuth client;
# writes the OAuth client creds into ./config/proxy-secrets.js; prints # writes the OAuth client creds into ./config/proxy-secrets.js; prints
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. Also seeds # CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout.
# 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. # 6. docker compose up -d --build proxy; wait for /health.
# 7. Register <SSO_HOST> and <PROXY_HOST> as Host records in the proxy (via # 7. Print the first-admin login + the public URLs.
# `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). # Requires: git, docker + docker compose (v1 standalone or v2 plugin).
@@ -88,23 +70,6 @@ rand_hex() {
fi 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
}
# Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`). # Detect docker compose (v2 plugin `docker compose` or v1 standalone `docker-compose`).
if docker compose version >/dev/null 2>&1; then if docker compose version >/dev/null 2>&1; then
COMPOSE=(docker compose) COMPOSE=(docker compose)
@@ -141,113 +106,15 @@ parse_kv_file() {
done < "$file" done < "$file"
} }
# ── 0. Self-update: pull theta-env itself, then restart with the new version ── # ── 1. Update submodules to latest, verify build contexts ─────────────────────
# 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_ENV_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-env (${BEFORE_VER} -> ${AFTER_VER}) — restarting setup.sh with the new version..."
THETA_ENV_REEXECED=1 exec "$0" "$@"
fi
else
warn "Could not fast-forward theta-env to the latest upstream (offline, or local changes) — continuing with the current checkout."
fi
fi
# ── Optional jump host: resolve the enable flag early ─────────────────────────
# CFG_JUMP_HOST_ENABLED gates the optional SSH jump host (a third submodule).
# Read it from the environment or ./setup.env now (before the submodule loop
# and the compose steps) so every run knows whether to build/start it. The
# authoritative CFG_* for secrets are still resolved in ensure_config; this is
# only the on/off switch + its hostname.
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
JUMP_ENABLED=0
case "${CFG_JUMP_HOST_ENABLED:-}" in 1|true|TRUE|yes|YES) JUMP_ENABLED=1 ;; esac
export CFG_JUMP_HOST_ENABLED CFG_JUMP_HOST
# When enabled, activate the compose profile so `up`/`ps` include the service.
if [[ "$JUMP_ENABLED" == "1" ]]; then export COMPOSE_PROFILES="jump-host"; fi
# ── 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 [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
if ! command -v git >/dev/null 2>&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." die "git not found. Install git, or set SKIP_SUBMODULE_UPDATE=1 to build the pinned submodule commits."
fi fi
if ! git submodule update --init --recursive 2>&1; then info "Updating submodules to latest (sso-manager-node, proxy)..."
die "git submodule update --init failed. Run manually: git submodule update --init --recursive" if ! git submodule update --init --remote --recursive 2>&1; then
warn "git submodule update failed (offline?) — continuing with the currently checked-out code."
fi fi
# jump-host is optional: only track/build it when enabled.
SUBMODULES=(sso-manager-node proxy)
[[ "$JUMP_ENABLED" == "1" ]] && SUBMODULES+=(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 else
info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)." info "Skipping submodule update (SKIP_SUBMODULE_UPDATE=1)."
fi fi
@@ -258,22 +125,11 @@ fi
|| die "proxy/Dockerfile missing. Run: git submodule update --init --recursive" || die "proxy/Dockerfile missing. Run: git submodule update --init --recursive"
# ── 2. ensure_config ────────────────────────────────────────────────────────── # ── 2. ensure_config ──────────────────────────────────────────────────────────
# Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar). Only used to # Derive a DNS domain from a base DN (dc=foo,dc=bar -> foo.bar).
# 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() { domain_from_dn() {
echo "$1" | sed 's/^dc=//; s/,dc=/./g' 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'
}
# Write ./config/sso-secrets.js from the CFG_* shell vars. # Write ./config/sso-secrets.js from the CFG_* shell vars.
write_sso_secrets() { write_sso_secrets() {
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN" local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
@@ -281,7 +137,7 @@ write_sso_secrets() {
cat > "$CONFIG_DIR/sso-secrets.js" <<SSOEOF cat > "$CONFIG_DIR/sso-secrets.js" <<SSOEOF
'use strict'; 'use strict';
// Generated by setup.sh. Edit freely; re-run ./setup.sh to apply. // 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 SSO app reads this via @simpleworkjs/conf (symlinked to conf/secrets.js).
// The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by // The app ignores the extra stack/bootstrap/serviceAccountPass keys (read by
// the orchestrator). Back this file up off-host — it holds all SSO secrets. // the orchestrator). Back this file up off-host — it holds all SSO secrets.
@@ -293,8 +149,6 @@ module.exports = {
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"), bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
userBase: $(js_str "ou=people,${dn}"), userBase: $(js_str "ou=people,${dn}"),
groupBase: $(js_str "ou=groups,${dn}"), groupBase: $(js_str "ou=groups,${dn}"),
ldapsHost: $(js_str "${CFG_LDAPS_HOST:-}"),
ldapsPort: 636,
}, },
smtp: { smtp: {
host: $(js_str "${CFG_SMTP_HOST:-}"), host: $(js_str "${CFG_SMTP_HOST:-}"),
@@ -309,22 +163,11 @@ module.exports = {
jwtSecret: $(js_str "$CFG_JWT_SECRET"), jwtSecret: $(js_str "$CFG_JWT_SECRET"),
token_lifetime: { access_token: 3600, refresh_token: 2592000 }, 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) ─────────────────────────────── // ── Orchestrator-only (ignored by the app) ───────────────────────────────
stack: { stack: {
ldapBaseDn: $(js_str "$dn"), ldapBaseDn: $(js_str "$dn"),
ldapDomain: $(js_str "$domain"), ldapDomain: $(js_str "$domain"),
siteName: $(js_str "${CFG_SITE_NAME:-local}"),
ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"), ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"),
ssoHost: $(js_str "$CFG_SSO_HOST"), ssoHost: $(js_str "$CFG_SSO_HOST"),
proxyHost: $(js_str "$CFG_PROXY_HOST"), proxyHost: $(js_str "$CFG_PROXY_HOST"),
@@ -345,8 +188,8 @@ write_proxy_secrets() {
local dn="$CFG_BASE_DN" local dn="$CFG_BASE_DN"
cat > "$CONFIG_DIR/proxy-secrets.js" <<PROXYEOF cat > "$CONFIG_DIR/proxy-secrets.js" <<PROXYEOF
'use strict'; 'use strict';
// Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (CONF_SECRETS // Generated by setup.sh. The proxy reads this via @simpleworkjs/conf (symlinked
// env var). clientId/clientSecret are filled in by the bootstrap // to conf/secrets.js). clientId/clientSecret are filled in by the bootstrap
// (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal // (run by ./setup.sh) — leave them as-is. ldap.bindPassword MUST equal
// serviceAccountPass in sso-secrets.js (the proxy binds as that account). // serviceAccountPass in sso-secrets.js (the proxy binds as that account).
@@ -378,10 +221,6 @@ module.exports = {
adminGroups: ['app_sso_admin'], adminGroups: ['app_sso_admin'],
adminUsers: ['proxyadmin2'], adminUsers: ['proxyadmin2'],
groupRoleMap: {}, 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: { stack: {
ssoHost: $(js_str "$CFG_SSO_HOST"), ssoHost: $(js_str "$CFG_SSO_HOST"),
@@ -392,32 +231,14 @@ PROXYEOF
} }
ensure_config() { 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 if [[ -f "$CONFIG_DIR/sso-secrets.js" ]]; then
info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)." info "Using existing $CONFIG_DIR/sso-secrets.js (operator-owned — left untouched)."
return 0 return 0
fi fi
# First run: read the domain/hosts from ./setup.env — the ONE place the # 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 # domain is entered, as the LDAP base DN (e.g. dc=718it,dc=biz). Hostnames
# DN is derived from it (dc=718it,dc=biz). Hostnames default to # default to sso.<domain> / proxy.<domain>, derived from it. setup.env is
# sso.<domain> / proxy.<domain>, also derived from it. setup.env is
# used ONLY on first run; once ./config/*.js exist they are operator-owned # 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 # and setup.env is ignored. Falls back to legacy .env/proxy.env migration
# below for existing deployments. # below for existing deployments.
@@ -432,21 +253,18 @@ BAOEOF
# derivation block further down (no example.com placeholders here). # derivation block further down (no example.com placeholders here).
CFG_BASE_DN="${CFG_BASE_DN:-}" CFG_BASE_DN="${CFG_BASE_DN:-}"
CFG_DOMAIN="${CFG_DOMAIN:-}" CFG_DOMAIN="${CFG_DOMAIN:-}"
CFG_SITE_NAME="${CFG_SITE_NAME:-}"
CFG_ORG="${CFG_ORG:-}" CFG_ORG="${CFG_ORG:-}"
CFG_SSO_HOST="${CFG_SSO_HOST:-}" CFG_SSO_HOST="${CFG_SSO_HOST:-}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-}" CFG_PROXY_HOST="${CFG_PROXY_HOST:-}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-}" CFG_ADMIN_UID="${CFG_ADMIN_UID:-}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}" CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}" CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}" CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}" CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}" CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}"
CFG_JWT_SECRET="${CFG_JWT_SECRET:-}" CFG_JWT_SECRET="${CFG_JWT_SECRET:-}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}" CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-}"
CFG_SVC_PASS="${CFG_SVC_PASS:-}" CFG_SVC_PASS="${CFG_SVC_PASS:-}"
CFG_PROXY_ADMIN_PASS="${CFG_PROXY_ADMIN_PASS:-}"
# ── One-time migration from .env / proxy.env (existing deployments) ── # ── One-time migration from .env / proxy.env (existing deployments) ──
# Preserve the operator's existing secrets so the running deployment keeps # Preserve the operator's existing secrets so the running deployment keeps
@@ -468,8 +286,6 @@ BAOEOF
CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}" CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}"
CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}" CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}"
CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}" 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_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}"
CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}" CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}"
CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}" CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}"
@@ -488,23 +304,17 @@ BAOEOF
migrated=1 migrated=1
fi fi
# Derive everything from the domain — the one value operators enter. No # Derive everything from the base DN — the one domain value. No example.com
# example.com defaults: a blank domain means first-run setup hasn't been # defaults: a blank base DN means first-run setup hasn't been done yet.
# done yet. CFG_BASE_DN can still be set directly (setup.env or a migrated [[ -n "$CFG_BASE_DN" ]] \
# .env) to override the derived DN or to read the domain back out of an || die "First run: 'cp setup.env.example setup.env', set CFG_BASE_DN to your domain (e.g. dc=718it,dc=biz), then re-run ./setup.sh"
# old-style DN-first setup.env; if not, it's built from CFG_DOMAIN. CFG_DOMAIN="${CFG_DOMAIN:-$(domain_from_dn "$CFG_BASE_DN")}"
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_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$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_ORG="${CFG_ORG:-SSO Manager}"
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}" CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}" CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}" CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}" CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}" CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
# Random secrets (generated fresh unless sourced/migrated above). These do # Random secrets (generated fresh unless sourced/migrated above). These do
@@ -513,12 +323,10 @@ BAOEOF
CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}" CFG_JWT_SECRET="${CFG_JWT_SECRET:-$(rand_hex 32)}"
CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}" CFG_ADMIN_PASS="${CFG_ADMIN_PASS:-$(rand_hex 16)}"
CFG_SVC_PASS="${CFG_SVC_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" mkdir -p "$CONFIG_DIR" && chmod 700 "$CONFIG_DIR"
write_sso_secrets write_sso_secrets
write_proxy_secrets write_proxy_secrets
chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js" chmod 600 "$CONFIG_DIR/sso-secrets.js" "$CONFIG_DIR/proxy-secrets.js"
if [[ "$migrated" == "1" ]]; then if [[ "$migrated" == "1" ]]; then
@@ -656,7 +464,7 @@ backup_before_rebuild() {
# Only prune real backup dirs — skip symlinks (a stray symlink could # Only prune real backup dirs — skip symlinks (a stray symlink could
# point rm at an arbitrary tree) and non-dir entries. # point rm at an arbitrary tree) and non-dir entries.
[[ -d "$BACKUP_DIR/$old" && ! -L "$BACKUP_DIR/$old" ]] || continue [[ -d "$BACKUP_DIR/$old" && ! -L "$BACKUP_DIR/$old" ]] || continue
rm -rf "${BACKUP_DIR:?}/$old" || true rm -rf "$BACKUP_DIR/$old" || true
removed=$((removed + 1)) removed=$((removed + 1))
done < <(ls -1 "$BACKUP_DIR" 2>/dev/null | sort -r | tail -n +$((keep + 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)." [[ "$removed" -gt 0 ]] && info " pruned $removed old backup(s) (keeping $keep)."
@@ -664,167 +472,7 @@ backup_before_rebuild() {
} }
backup_before_rebuild backup_before_rebuild
# ── 3b. Setup OpenBao (Vault) ────────────────────────────────────────────────
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 ! docker exec openbao bao status -format=json 2>/dev/null | grep -q '"initialized": true' || 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
info "Unsealing openbao..."
UNSEAL_KEY=$(grep -A1 '"unseal_keys_b64":' "$CONFIG_DIR/bao-init.json" | tail -n1 | cut -d'"' -f2)
docker exec openbao bao operator unseal "$UNSEAL_KEY" >/dev/null
fi
export VAULT_TOKEN
VAULT_TOKEN=$(grep '"root_token":' "$CONFIG_DIR/bao-init.json" | cut -d'"' -f4)
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"
if bao_run policy read "$name" >/dev/null 2>&1; then
info " policy ${name} already exists — keeping."
else
info " writing policy ${name}..."
docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao policy write "$name" - >/dev/null
fi
}
# Read KEY= from ./.env (empty if absent) — reuse a previously minted token
# instead of minting a fresh one on every setup.sh run.
env_get() {
local key="$1" file=./.env
[[ -f "$file" ]] || return 0
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2-
}
# Mint an orphan, renewable token for `policy` and persist it to .env as `key`,
# OR reuse the token already in .env if it is still valid (re-mint on expiry).
ensure_token() {
local key="$1" policy="$2" existing tok
existing="$(env_get "$key")"
if [[ -n "$existing" ]] && docker exec -e BAO_TOKEN="$existing" openbao bao token lookup >/dev/null 2>&1; then
info " ${key} already minted + valid — keeping."
return 0
fi
info " minting ${key} (policy=${policy})..."
tok="$(bao_run token create -policy="$policy" -orphan=true -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.
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/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 "auth/token/create/sso-broker" { 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/.
ensure_policy sso-admin <<'HCL'
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
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/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
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 role: sso-broker (mints user-*/app-*/sso-admin tokens, 24h period)"
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env"
# ── 4. Start SSO Manager, wait for health ───────────────────────────────────── # ── 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)..." info "Building + starting sso-manager (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build sso-manager "${COMPOSE[@]}" up -d --build sso-manager
@@ -840,29 +488,18 @@ for i in $(seq 1 60); do
sleep 2 sleep 2
done done
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 # 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. # running container's node — works whether ./config was generated or pre-existing.
read_config_kv() { read_config_kv() {
"${COMPOSE[@]}" exec -T sso-manager node -e ' "${COMPOSE[@]}" exec -T sso-manager node -e '
const c = require("/config/sso-secrets.js"); const c = require("/config/sso-secrets.js");
let p = {};
try { p = require("/config/proxy-secrets.js"); } catch (_) {}
const o = { const o = {
SSO_HOST: (c.stack && c.stack.ssoHost) || "", SSO_HOST: (c.stack && c.stack.ssoHost) || "",
PROXY_HOST: (c.stack && c.stack.proxyHost) || "", PROXY_HOST: (c.stack && c.stack.proxyHost) || "",
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "", LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
ORG_NAME: c.name || "", ORG_NAME: c.name || "",
ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "", ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "",
ADMIN_PASS: (c.bootstrap && c.bootstrap.adminPass) || "",
}; };
for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k])); for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k]));
' 2>/dev/null ' 2>/dev/null
@@ -872,6 +509,7 @@ cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
SSO_HOST="$(cfgval SSO_HOST)" SSO_HOST="$(cfgval SSO_HOST)"
PROXY_HOST="$(cfgval PROXY_HOST)" PROXY_HOST="$(cfgval PROXY_HOST)"
ADMIN_UID="$(cfgval ADMIN_UID)" ADMIN_UID="$(cfgval ADMIN_UID)"
ADMIN_PASS="$(cfgval ADMIN_PASS)"
info "Stack config:" info "Stack config:"
info " SSO host: https://${SSO_HOST}" info " SSO host: https://${SSO_HOST}"
@@ -880,39 +518,14 @@ info " Admin uid: ${ADMIN_UID}"
# ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ── # ── 5. Run the bootstrap (writes CLIENT_ID/CLIENT_SECRET/ALREADY_CONFIGURED) ──
# The bootstrap reads its inputs from /config/*.js (not env) and writes the # 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 # generated OAuth client creds back into /config/proxy-secrets.js. No -e flags.
# 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)..." 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 BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/bootstrap.js) \
# 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)"
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-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_ENABLED="${CFG_JUMP_HOST_ENABLED:-}" \
-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}" || die "bootstrap failed:\n${BOOTSTRAP_OUT}"
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; } getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
CLIENT_ID=$(getval CLIENT_ID) CLIENT_ID=$(getval CLIENT_ID)
CLIENT_SECRET=$(getval CLIENT_SECRET)
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED) ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}" [[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
@@ -923,10 +536,6 @@ else
fi fi
# ── 6. Start the proxy, wait for health ─────────────────────────────────────── # ── 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)..." info "Building + starting proxy (first run builds the image; this takes a while)..."
"${COMPOSE[@]}" up -d --build proxy "${COMPOSE[@]}" up -d --build proxy
@@ -939,98 +548,7 @@ for i in $(seq 1 60); do
sleep 2 sleep 2
done done
# ── 7. Register the SSO + proxy UIs as Host records in the proxy ────────────── # ── 7. Summary ───────────────────────────────────────────────────────────────
# 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: 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. Optional: build + start the SSH jump host ─────────────────────────────
# Enabled by CFG_JUMP_HOST_ENABLED. The bootstrap (step 5) already wrote
# ./config/jump-secrets.js (minted API token + LDAP admin bind). Build/start the
# service (compose profile 'jump-host' is active), wait for its web /health, and
# register its web UI hostname as a proxy Host so https://<JUMP_HOST> routes.
if [[ "$JUMP_ENABLED" == "1" ]]; then
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 (optional; enabled via CFG_JUMP_HOST_ENABLED)..."
"${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: 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] /'
fi
# ── 8. Summary ───────────────────────────────────────────────────────────────
echo echo
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m" info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
echo echo
@@ -1038,21 +556,10 @@ 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 " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
echo " Proxy mgmt UI: https://${PROXY_HOST}" echo " Proxy mgmt UI: https://${PROXY_HOST}"
echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}" echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}"
if [[ "$JUMP_ENABLED" == "1" ]]; then
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)"
fi
echo echo
echo " First admin login credentials are in ./config/sso-secrets.js:" echo " First admin login:"
echo " user: ${ADMIN_UID}" echo " user: ${ADMIN_UID}"
echo " pass: bootstrap.adminPass" echo " pass: ${ADMIN_PASS}"
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
echo " Secrets live in ./config/ (sso-secrets.js + proxy-secrets.js). Back them" 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 " up off-host — ./setup.sh snapshots to ./backups/ before each rebuild."
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
set -e
echo "=== Starting theta-env Integration Tests ==="
echo "=> Cleaning up any existing containers and volumes..."
docker-compose down -v
echo "=> Running setup.sh to initialize environment..."
# Run setup non-interactively if possible (we might need to export some env vars)
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-env to test integration between all the include projects".
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
# Actually, setup.sh initializes Vault. If we don't run it, Vault is sealed!
# Let's just write a curl test that checks if the containers start.
docker-compose up -d
echo "=> Waiting for services to become healthy..."
sleep 15 # Give time for containers to spin up
# Test proxy
echo "=> Testing Proxy..."
if ! curl -sS -o /dev/null -w "%{http_code}" http://localhost | grep -q "406"; then
echo "❌ Proxy failed to respond with 406 Not Acceptable on port 80 (default behavior)"
exit 1
fi
echo "✅ Proxy responds on port 80"
# Test SSO Manager Node
echo "=> Testing SSO Manager..."
if ! curl -sS -f -o /dev/null http://localhost:3001; then
echo "❌ SSO Manager failed to respond on port 3001"
exit 1
fi
echo "✅ SSO Manager responds on port 3001"
echo "=== All integration tests passed! ==="
docker-compose down -v
exit 0
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env node
'use strict';
// Regression guard for bootstrap.js's generated jump-secrets.js template:
// its ldap block must use ldaps:// (implicit TLS, :636), never ldap:// (:389),
// as long as tlsOptions is set alongside it.
//
// ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL
// scheme, and jump-host's LDAP client always sets tlsOptions -- so ldap://
// + tlsOptions opens a raw TLS handshake against a port serving plaintext
// LDAP. The server silently drops the connection before any LDAP message
// parses, and every operation (getUser, checkPassword, ...) then fails
// identically -- indistinguishable from a wrong password. This shipped once
// (every SSH login to jump-host failed, for any account, any password) before
// being root-caused against a real deployment. Static, not a require()+exec
// of bootstrap.js, because bootstrap.js is a self-running provisioning script
// with real side effects (LDAP writes, API calls), not a library.
const fs = require('fs');
const path = require('path');
const BOOTSTRAP_PATH = path.join(__dirname, '..', 'bootstrap', 'bootstrap.js');
const src = fs.readFileSync(BOOTSTRAP_PATH, 'utf8');
// Isolate the generated jump-secrets.js template (the backtick string
// assigned to `body` inside writeJumpSecrets) rather than scanning the whole
// file, so this only ever looks at what's actually written to the deployed
// config -- not, say, a comment or an unrelated ldap:// URL elsewhere.
// bootstrap.js's own source has literal backslash-t escape sequences inside
// the backtick string (they only become real tabs when the template
// literal is actually evaluated) -- so these patterns match `\t` as two
// literal characters, not a real tab byte.
const bodyMatch = /const body = `([\s\S]*?)`;\n\tfs\.writeFileSync\(JUMP_SECRETS/.exec(src);
if (!bodyMatch) {
console.error('check_jump_ldap_tls: could not locate the jump-secrets.js template in bootstrap.js — did writeJumpSecrets change shape?');
process.exit(1);
}
const template = bodyMatch[1];
// Bounded by the next top-level key (sso:) rather than the ldap block's own
// closing brace, which is more robust to exactly how it's indented/escaped.
const ldapBlockMatch = /ldap:\s*\{([\s\S]*?)\\tsso:\s*\{/.exec(template);
if (!ldapBlockMatch) {
console.error('check_jump_ldap_tls: could not find the ldap: {...} block in the jump-secrets.js template.');
process.exit(1);
}
const ldapBlock = ldapBlockMatch[1];
const hasTlsOptions = /tlsOptions\s*:/.test(ldapBlock);
const urlMatch = /url:\s*'([^']+)'/.exec(ldapBlock);
const url = urlMatch ? urlMatch[1] : null;
if (!url) {
console.error('check_jump_ldap_tls: no url found in the ldap block.');
process.exit(1);
}
if (hasTlsOptions && !url.startsWith('ldaps://')) {
console.error(
`check_jump_ldap_tls: jump-secrets.js template sets tlsOptions but url is "${url}" (not ldaps://). ` +
'This is the exact bug that broke every SSH login to jump-host -- see the comment above this check.'
);
process.exit(1);
}
console.log(`check_jump_ldap_tls: OK (url=${url}, tlsOptions=${hasTlsOptions})`);