Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ba9ba989 |
+4
-13
@@ -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=
|
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
-1024
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
| [](docs/images/sso-dashboard.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,18 +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 — `./config/` (no `.env` files)
|
### Configuration — `./config/` (no `.env` files)
|
||||||
|
|
||||||
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
|
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
|
||||||
read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which
|
read by each app's `@simpleworkjs/conf` from a symlinked `secrets.js`:
|
||||||
the entrypoint points at the mounted file:
|
|
||||||
|
|
||||||
- **`./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
|
||||||
@@ -470,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)
|
||||||
@@ -484,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).
|
|
||||||
Vendored
+9
-364
@@ -44,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';
|
||||||
|
|
||||||
@@ -60,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';
|
||||||
@@ -229,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: [],
|
||||||
}),
|
}),
|
||||||
@@ -250,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,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.
|
||||||
@@ -478,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}`);
|
||||||
@@ -629,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);
|
||||||
@@ -642,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.
|
||||||
@@ -652,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);
|
||||||
@@ -660,30 +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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
|
||||||
} 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" )
|
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
+8
-130
@@ -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,8 +39,6 @@ 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:
|
||||||
@@ -64,18 +47,10 @@ services:
|
|||||||
# reads that are not part of its conf tree.
|
# reads that are not part of its conf tree.
|
||||||
- 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_TOKEN=${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
|
||||||
@@ -99,17 +74,6 @@ 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]
|
||||||
@@ -129,15 +93,10 @@ services:
|
|||||||
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
|
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NODE_PORT=3000
|
- NODE_PORT=3000
|
||||||
# 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.
|
# entrypoint symlinks /config/proxy-secrets.js -> /app/conf/secrets.js.
|
||||||
- ./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.
|
||||||
@@ -155,84 +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
|
|
||||||
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
|
|
||||||
# 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
|
||||||
@@ -243,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
-36
@@ -1,41 +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: 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
|
|
||||||
@@ -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 }} · {% 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>
|
|
||||||
© {{ 'now' | date: '%Y' }} theta42 ·
|
|
||||||
<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
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
|
|
||||||
+8
-9
@@ -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/`
|
||||||
|
|||||||
-1
Submodule jump-host deleted from f386a5f9c3
-1
Submodule ldap-client deleted from 31d8fa1229
@@ -1 +0,0 @@
|
|||||||
https://github.com/theta42/theta-env/pull/75
|
|
||||||
+1
-1
Submodule proxy updated: 30835baaeb...3df7d8c5cb
+11
-70
@@ -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"
|
|
||||||
@@ -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,54 +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
|
|
||||||
|
|
||||||
# ── 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
|
||||||
|
|
||||||
@@ -727,26 +488,18 @@ for i in $(seq 1 60); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
if ! docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf >/dev/null 2>&1; then
|
|
||||||
info "Seeding sso-manager/conf into Openbao..."
|
|
||||||
docker exec sso-manager node -e "console.log(JSON.stringify(require('/config/sso-secrets.js')))" > "$CONFIG_DIR/seed-conf.json"
|
|
||||||
cat "$CONFIG_DIR/seed-conf.json" | docker exec -i -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf -
|
|
||||||
rm -f "$CONFIG_DIR/seed-conf.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -756,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}"
|
||||||
@@ -766,31 +520,12 @@ info " Admin uid: ${ADMIN_UID}"
|
|||||||
# 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. No -e flags.
|
# generated OAuth client creds back into /config/proxy-secrets.js. No -e flags.
|
||||||
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:-}" \
|
|
||||||
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}"
|
||||||
|
|
||||||
@@ -801,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
|
||||||
|
|
||||||
@@ -817,93 +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"
|
|
||||||
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
|
||||||
@@ -911,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."
|
||||||
|
|||||||
+1
-1
Submodule sso-manager-node updated: aa17981c15...11fb2c0a54
@@ -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})`);
|
|
||||||
Reference in New Issue
Block a user