Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d0496cfda | |||
| 48df638ddd | |||
| d6d2c7144a | |||
| 67374dc914 | |||
| a3b41c6775 | |||
| b25fb56a0d | |||
| a15002b588 | |||
| fe8133b21c | |||
| c83248e40b | |||
| e5a5eef428 | |||
| d098ba7082 | |||
| 1f8f4c70be | |||
| 9671339076 | |||
| aa74ca1b8c | |||
| cf919de1a6 | |||
| 3e85e37b63 | |||
| 75278f3e16 | |||
| faf67d8ffc | |||
| 892069eaea | |||
| 4f61eeb1a7 | |||
| f51b7e2dbe | |||
| 1c96c75118 | |||
| 5c8e5be0c5 | |||
| 4b613c7ca1 | |||
| a118f7ad4e | |||
| 87c8c0fc02 | |||
| 43cdf1dbbb | |||
| 9a737dd178 | |||
| fea1237c46 | |||
| 0b55535aa9 | |||
| 2baf8acd64 | |||
| 3943ed02c5 | |||
| 7f43eee36e | |||
| 19ea7e012a | |||
| 94b357e915 | |||
| f5d8cdd09d | |||
| 96b3aec5eb | |||
| 5aff5349a8 | |||
| 3b0f8f1f9a | |||
| 28016376ad | |||
| daa48dd154 | |||
| a226ff9d00 | |||
| 65a4c1d839 | |||
| 005c66d3f4 | |||
| e46768bb68 |
+13
-4
@@ -14,7 +14,8 @@
|
||||
LDAP_BASE_DN=dc=example,dc=com
|
||||
# DNS domain (dc=foo,dc=bar -> foo.bar). Leave blank to derive from LDAP_BASE_DN.
|
||||
LDAP_DOMAIN=
|
||||
LDAP_ADMIN_PASS=change-me-ldap-admin-password
|
||||
# LDAP admin password. MUST be changed. Leave blank and setup.sh will generate one.
|
||||
LDAP_ADMIN_PASS=CHANGE-ME
|
||||
ORG_NAME="My Org"
|
||||
|
||||
# ── Public hostnames (REQUIRED) ───────────────────────────────────────────────
|
||||
@@ -30,13 +31,15 @@ PROXY_HOST=proxy.example.com
|
||||
# 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.
|
||||
BOOTSTRAP_ADMIN_UID=admin
|
||||
BOOTSTRAP_ADMIN_PASS=change-me-admin-password
|
||||
# First admin password. MUST be changed. Leave blank and setup.sh will generate one.
|
||||
BOOTSTRAP_ADMIN_PASS=CHANGE-ME
|
||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# ── Proxy LDAP service account (created by the bootstrap) ────────────────────
|
||||
# The proxy binds to LDAP as cn=ldapclient,ou=people,<base> with this password.
|
||||
# Re-running setup.sh resets it to LDAP_SERVICE_PASS.
|
||||
LDAP_SERVICE_PASS=change-me-ldap-service-password
|
||||
# LDAP service-account password. MUST be changed. Leave blank and setup.sh will generate one.
|
||||
LDAP_SERVICE_PASS=CHANGE-ME
|
||||
|
||||
# ── OAuth JWT secret (REQUIRED — persist it) ────────────────────────────────
|
||||
# Signs the SSO's access/refresh tokens. Generate with: openssl rand -hex 32
|
||||
@@ -73,4 +76,10 @@ MGMT_BIND=0.0.0.0
|
||||
# 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
|
||||
# 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=
|
||||
@@ -4,3 +4,7 @@
|
||||
[submodule "proxy"]
|
||||
path = proxy
|
||||
url = https://github.com/theta42/proxy.git
|
||||
[submodule "jump-host"]
|
||||
path = jump-host
|
||||
url = https://github.com/theta42/jump-host.git
|
||||
branch = master
|
||||
|
||||
+191
-1
@@ -10,6 +10,196 @@ for what changed inside the apps it composes.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.7] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- The bootstrap now provisions the jump host's **web-UI SSO login** when the jump host is enabled: it mints a dedicated `theta-jump` OAuth client and writes a full `oidc` block (endpoints, client id/secret, callback) plus a generated local anti-lockout admin password into `./config/jump-secrets.js`. Matches how the proxy's OIDC client is provisioned. An existing pre-OIDC `jump-secrets.js` (API token but no OIDC client) is regenerated so upgraders get SSO login. Requires jump-host ≥ v1.1.0.
|
||||
|
||||
## [1.3.6] - 2026-07-23
|
||||
|
||||
### Bumped
|
||||
- sso-manager-node -> [v1.3.2](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.2)
|
||||
|
||||
sso-manager-node 1.3.2:
|
||||
|
||||
### Fixed
|
||||
- **OAuth client management API returned `client_id: undefined` on every GET**, which broke this stack's bootstrap: it lists the OAuth clients and rotates by the returned `client_id`, so it called `/api/oauth/client/undefined/rotate` and got a 500 — aborting `setup.sh` with `bootstrap failed` whenever `proxy-secrets.js` had no usable secret (e.g. a fresh/rotated deployment). The ORM's `toJSON()` was stripping the mapped `client_id`/`scopes`/… fields; `OAuthClient.get()` now emits them explicitly (and omits `client_secret_hash`). Unknown client ids now 404 instead of 500.
|
||||
|
||||
## [1.3.5] - 2026-07-23
|
||||
|
||||
### Added
|
||||
- **Optional SSH jump host** (theta42/jump-host) as a third, opt-in submodule. Enable with `CFG_JUMP_HOST_ENABLED=true` in `setup.env`: setup.sh clones/tag-tracks the submodule and builds it behind the `jump-host` compose profile, the bootstrap mints a directory API token and writes `./config/jump-secrets.js` (LDAP admin bind so it can inject users' `sshPublicKey`), the jump host is registered as a proxy Host (its web UI) and seeded as a directory service. Users then `ssh uid_-_host@jump.<domain>` (WinSCP-friendly) or `ssh uid@jump.<domain>` for a TUI host picker; the web UI on :3002 shows audit + metrics. Off by default — existing installs are unaffected.
|
||||
|
||||
## [1.3.4] - 2026-07-23
|
||||
|
||||
### Bumped
|
||||
- sso-manager-node -> [v1.3.1](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.1)
|
||||
|
||||
sso-manager-node 1.3.1:
|
||||
|
||||
### Added
|
||||
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
|
||||
|
||||
### Changed
|
||||
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
|
||||
|
||||
### theta-env own changes
|
||||
|
||||
### Added
|
||||
- `CFG_SITE_NAME` in `setup.env` (right below `CFG_DOMAIN`, default `local`): names the SSO directory site the stack registers itself under — slug `site_<name>`, matching the `parentSlug` convention ldap-client-joined Linux hosts use, so they land under the same site.
|
||||
- The directory seed now collects real host facts on the machine (hostname, IP, MAC of the default-route interface, OS pretty-name, kernel — same collection as `ldap-client/index.sh`) and registers the stack host as `host_<hostname>` with that metadata, plus fills in each service's internal port and git repo (`sso-manager` 3001, `proxy` 3000, `openldap` 389/636, `openresty` 443). Existing resources from the earlier seed layout (`stack-host`, domain-slug site) are adopted in place — seed metadata only fills fields the operator hasn't set, never overwrites.
|
||||
- The bootstrap now seeds the SSO directory with the stack's own resources: a site (from the configured domain), a "Stack host", and the SSO Manager + Proxy services (with their public URLs in metadata), linking the proxy's auto-registered OAuth client under its service. Also seeds the two non-obvious services the stack runs: the OpenLDAP directory (advertising the `ldaps://` endpoint Linux hosts and LDAP-native apps bind to, honoring `ldap.ldapsHost`) and the OpenResty edge (the 80/443 data plane every hostname flows through, with a wildcard `https://*.<domain>` address). The Directory page is populated out of the box instead of starting empty. Idempotent — resources whose slug already exists are operator-owned and never touched, and a seed failure only warns (never fails a bring-up, e.g. against an older sso-manager image without `/api/directory`).
|
||||
|
||||
## [1.3.3] - 2026-07-23
|
||||
|
||||
### Bumped
|
||||
- sso-manager-node -> [v1.3.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.3.0) (from v1.1.18; includes the intermediate v1.2.1 release)
|
||||
|
||||
sso-manager-node 1.3.0:
|
||||
|
||||
### Added
|
||||
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
|
||||
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
|
||||
|
||||
### Fixed
|
||||
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
|
||||
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
|
||||
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
|
||||
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
|
||||
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
|
||||
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
|
||||
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
|
||||
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
|
||||
|
||||
### Changed
|
||||
- Removed the Mobile Phone field from the user create/edit form.
|
||||
|
||||
sso-manager-node 1.2.1:
|
||||
|
||||
### Added
|
||||
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
|
||||
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
|
||||
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
|
||||
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
|
||||
|
||||
## [1.3.2] - 2026-07-21
|
||||
|
||||
### Bumped
|
||||
- proxy -> [v1.2.2](https://github.com/theta42/proxy/releases/tag/v1.2.2)
|
||||
|
||||
proxy:
|
||||
|
||||
### Fixed
|
||||
- Multi-target load balancing (added in 1.2.0) crashed every request to a load-balanced host: `ops/nginx_conf/targetinfo.lua` required a nonexistent `resty.balancer.round_robin` module. The `lua-resty-balancer` rock actually installed provides `resty.roundrobin` instead, with a different constructor API. Fixed `targetinfo.lua` to use the real module — verified end-to-end that requests now round-robin across targets with no Lua errors.
|
||||
|
||||
## [1.3.1] - 2026-07-21
|
||||
|
||||
### Bumped
|
||||
- proxy -> [v1.2.1](https://github.com/theta42/proxy/releases/tag/v1.2.1)
|
||||
- sso-manager-node -> [v1.1.18](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.18)
|
||||
|
||||
proxy:
|
||||
|
||||
### Fixed
|
||||
- The bootstrap anti-lockout admin account was always created as `proxyadmin2` regardless of `conf.auth.adminUsers`, while `migrations/permission_bootstrap.js` grants the global-admin permission to `conf.auth.adminUsers[0]`. If an operator customized `adminUsers` away from the default, the bootstrapped account and the permissioned account were two different (non-matching) usernames, so the anti-lockout account ended up with no admin access. `models/user_redis.js` now derives the bootstrap username from `conf.auth.adminUsers[0]` (falling back to `proxyadmin2`), matching `permission_bootstrap.js`.
|
||||
- Corrected a `secrets.js.example` comment that claimed the bootstrap admin's password "defaults to the username itself" — it actually generates a random password printed to the container log on first boot.
|
||||
|
||||
### Changed
|
||||
- Refreshed all README screenshots (hosts, per-host SSO auth, per-host basic auth) against the current UI, and added a new load-balancing screenshot for the multi-target feature.
|
||||
|
||||
sso-manager-node:
|
||||
|
||||
### Added
|
||||
- N-Way Multi-Master LDAP replication: `LDAP_SERVER_ID` + `LDAP_REPLICATION_HOSTS` configure `syncrepl` peers in the bundled OpenLDAP, and a new `/sites` page (nav: **Sites**) shows each configured peer's LDAP URL and live reachability.
|
||||
- A `location` property on users, editable from the profile and user-edit forms.
|
||||
|
||||
### Fixed
|
||||
- `/sites` (added above) 500'd on every load: `views/sites.ejs` included nonexistent partials `header`/`footer` instead of this app's actual `top`/`bottom`. Fixed to match every other view.
|
||||
|
||||
### Changed
|
||||
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
|
||||
|
||||
### theta-env own changes
|
||||
- Refreshed `docs/images/sso-dashboard.png` and `docs/images/proxy-hosts.png` to match the submodules' updated screenshots.
|
||||
|
||||
## [1.1.20] - 2026-07-20
|
||||
|
||||
### Bumped
|
||||
- proxy -> [v1.1.17](https://github.com/theta42/proxy/releases/tag/v1.1.17)
|
||||
|
||||
proxy:
|
||||
|
||||
### Fixed
|
||||
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
|
||||
|
||||
## [1.1.19] - 2026-07-18
|
||||
|
||||
### Bumped
|
||||
- sso-manager-node -> [v1.1.17](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.17)
|
||||
|
||||
sso-manager-node:
|
||||
|
||||
### Added
|
||||
- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options for advertising a separate, internal-only LDAPS hostname on the `/integrations` page. Falls back to the public OAuth issuer host when unset.
|
||||
- Contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname, why port 636 should not be forwarded publicly, and the recommended internal-DNS / Docker-internal alternatives.
|
||||
- Tests for the `/integrations` route's LDAPS URL derivation and `ldapsHost` override.
|
||||
|
||||
### Changed
|
||||
- `nodejs/package.json` / `package-lock.json` version bumped to `1.1.17`.
|
||||
- `routes/index.js` now derives the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host.
|
||||
- `docs/configuration.md`, `docs/ldap.md`, `DEPLOYMENT.md`, and `secrets.js.example` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
|
||||
|
||||
### theta-env own changes
|
||||
- `setup.env.example` adds optional `CFG_LDAPS_HOST` for the internal LDAPS hostname.
|
||||
- `setup.sh` passes `CFG_LDAPS_HOST` into the generated `./config/sso-secrets.js` as `ldap.ldapsHost`.
|
||||
- `config.example/sso-secrets.js.example` documents `ldap.ldapsHost` / `ldap.ldapsPort`.
|
||||
- `.env.example` adds `LDAPS_HOST` for legacy `.env` migrations.
|
||||
- `docker-compose.yml` comments warn against forwarding 636 to the public internet.
|
||||
- `README.md` explains the `CFG_LDAPS_HOST` recommendation in the port-forwarding section.
|
||||
|
||||
## [1.1.18] - 2026-07-18
|
||||
|
||||
### Bumped
|
||||
- proxy -> [v1.1.16](https://github.com/theta42/proxy/releases/tag/v1.1.16)
|
||||
- sso-manager-node -> [v1.1.16](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.16)
|
||||
|
||||
proxy:
|
||||
|
||||
### Changed
|
||||
- Public-release packaging: removed `"private": true` from `nodejs/package.json`, corrected the repository URL to `https://github.com/theta42/proxy.git`, and fixed the MIT `LICENSE` copyright line.
|
||||
- Genericized committed config defaults in `conf/base.js` and `conf/development.js` (`example.com` / `localhost` instead of theta42 infrastructure).
|
||||
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default.
|
||||
|
||||
### Security
|
||||
- Sanitized rendered docs HTML with `xss` in `routes/docs.js`.
|
||||
- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`.
|
||||
|
||||
### Fixed
|
||||
- The global error handler no longer leaks `err.keys`, stack traces, or internal details in JSON responses.
|
||||
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism.
|
||||
|
||||
sso-manager-node:
|
||||
|
||||
### Security
|
||||
- Hardened LDAP filter and DN construction against injection in `models/group_ldap.js` and `models/user_ldap.js`.
|
||||
- Replaced `Math.random()`-based token/UUID/OTP generation with `crypto.randomUUID()` / `crypto.randomInt()` in `models/token.js`, `models/oauth_code.js`, and `models/oauth_client.js`.
|
||||
- Refused startup when `oauth.jwtSecret` is missing or placeholder.
|
||||
- Sanitized rendered docs/Terms-of-Service HTML with `xss` to block malicious markdown output.
|
||||
- Removed full-object `console.log` of new-user data and reduced login error logging to `name`/`message` only.
|
||||
|
||||
### Changed
|
||||
- Public-release packaging: removed `"private": true` from `nodejs/package.json` and bumped version to `1.1.16`.
|
||||
|
||||
### Fixed
|
||||
- `models/email.js`: fixed from-address template rendering bug.
|
||||
|
||||
### theta-env own changes
|
||||
- `CHANGELOG.md` now embeds the full app-level release notes for each submodule bump, not just links.
|
||||
- `.env.example` no longer ships realistic-looking default passwords; values are clearly placeholders.
|
||||
- `config.example/*.js.example` comments now describe the actual `CONF_SECRETS` env-var loading mechanism.
|
||||
- `setup.sh` summary no longer prints generated passwords to stdout; it points to `./config/*.js`.
|
||||
- `bootstrap/bootstrap.js` fails hard instead of falling back to weak default passwords when config is missing.
|
||||
|
||||
## [1.1.17] - 2026-07-18
|
||||
|
||||
### Bumped
|
||||
@@ -214,7 +404,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention going forward.
|
||||
- proxy -> [v1.1.0](https://github.com/theta42/proxy/releases/tag/v1.1.0)
|
||||
- sso-manager-node -> [v1.1.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0)
|
||||
|
||||
[Unreleased]: https://github.com/theta42/theta-env/compare/v1.1.17...HEAD
|
||||
[Unreleased]: https://github.com/theta42/theta-env/compare/v1.1.20...HEAD
|
||||
[1.1.17]: https://github.com/theta42/theta-env/compare/v1.1.16...v1.1.17
|
||||
[1.1.16]: https://github.com/theta42/theta-env/compare/v1.1.15...v1.1.16
|
||||
[1.1.15]: https://github.com/theta42/theta-env/compare/v1.1.14...v1.1.15
|
||||
|
||||
@@ -60,6 +60,10 @@ 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
|
||||
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
|
||||
@@ -120,6 +124,10 @@ Optional extra ports (only if you need them):
|
||||
- **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
|
||||
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
|
||||
|
||||
|
||||
Vendored
+343
-9
@@ -44,8 +44,15 @@ const fs = require('fs');
|
||||
const sso = require('/config/sso-secrets.js');
|
||||
const proxy = require('/config/proxy-secrets.js');
|
||||
|
||||
const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com';
|
||||
const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin';
|
||||
function requireConf(value, name) {
|
||||
if (value === undefined || value === null || value === '' || value === 'CHANGE-ME') {
|
||||
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 LDAP_URL = 'ldap://localhost:389';
|
||||
|
||||
@@ -53,9 +60,9 @@ const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||
// 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 —
|
||||
// two different accounts, two different secrets.
|
||||
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin';
|
||||
const ADMIN_USER_PASS = requireConf((sso.bootstrap && sso.bootstrap.adminPass), 'bootstrap.adminPass');
|
||||
const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || '';
|
||||
const SVC_PASS = sso.serviceAccountPass || 'service';
|
||||
const SVC_PASS = requireConf(sso.serviceAccountPass, 'serviceAccountPass');
|
||||
|
||||
const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com';
|
||||
const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com';
|
||||
@@ -222,14 +229,15 @@ async function listClients(token) {
|
||||
return (data && data.results) || [];
|
||||
}
|
||||
|
||||
async function createClient(token) {
|
||||
async function createClient(token, opts) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: CLIENT_NAME,
|
||||
description: 'theta-env proxy (auto-registered)',
|
||||
redirect_uris: [REDIRECT_URI],
|
||||
name: o.name,
|
||||
description: o.description,
|
||||
redirect_uris: o.redirect_uris,
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
allowed_groups: [],
|
||||
}),
|
||||
@@ -242,7 +250,7 @@ async function createClient(token) {
|
||||
const id = (data.results && data.results.client_id) || data.client_id;
|
||||
const secret = data.client_secret;
|
||||
if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`);
|
||||
log(`Created OAuth client ${CLIENT_NAME} (${id})`);
|
||||
log(`Created OAuth client ${o.name} (${id})`);
|
||||
return { id, secret };
|
||||
}
|
||||
|
||||
@@ -261,6 +269,182 @@ async function rotateClient(token, id) {
|
||||
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) {
|
||||
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.
|
||||
if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) {
|
||||
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
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 the proxy's OAuth client (Resource-backed since sso-manager 1.3.0)
|
||||
// under its service, if it appears in the directory and isn't linked yet.
|
||||
if (clientId) {
|
||||
const oauthRes = resources.find((r) => r.id === clientId);
|
||||
if (oauthRes) {
|
||||
const edges = ((await dirGet(token, 'edges')).results) || [];
|
||||
const linked = edges.some((e) => e.childId === clientId);
|
||||
if (!linked) {
|
||||
await dirPost(token, 'edges', { parentId: psvc.id, childId: clientId, relation: 'oauth' });
|
||||
log(` directory: linked OAuth client under '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
|
||||
// are touched; the rest of the file (operator edits, comments) is preserved.
|
||||
@@ -291,6 +475,131 @@ 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\turl: 'ldap://sso-manager:389',
|
||||
\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 });
|
||||
}
|
||||
|
||||
async function provisionJumpHost(token) {
|
||||
if (jumpFileComplete()) {
|
||||
log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.');
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
log(`Base DN: ${BASE_DN}`);
|
||||
@@ -300,6 +609,7 @@ function writeProxyCreds(id, secret) {
|
||||
|
||||
const list = await listClients(token);
|
||||
// Find the proxy's client: by id if we have usable creds, else by name.
|
||||
let resolvedClientId = '';
|
||||
let client = null;
|
||||
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
|
||||
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
|
||||
@@ -312,6 +622,7 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', EXISTING_ID);
|
||||
out('CLIENT_SECRET', EXISTING_SECRET);
|
||||
out('ALREADY_CONFIGURED', '1');
|
||||
resolvedClientId = EXISTING_ID;
|
||||
} else if (client) {
|
||||
// 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.
|
||||
@@ -321,6 +632,7 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', id);
|
||||
out('CLIENT_SECRET', secret);
|
||||
out('ALREADY_CONFIGURED', '0');
|
||||
resolvedClientId = id;
|
||||
} else {
|
||||
// No client yet — create one and write the generated creds back.
|
||||
const { id, secret } = await createClient(token);
|
||||
@@ -328,7 +640,29 @@ function writeProxyCreds(id, secret) {
|
||||
out('CLIENT_ID', id);
|
||||
out('CLIENT_SECRET', secret);
|
||||
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.
|
||||
if (JUMP_ENABLED) {
|
||||
try {
|
||||
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);
|
||||
} catch (e) {
|
||||
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
|
||||
log('Done.');
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
|
||||
// file documents the shape for manual editing / reference.
|
||||
//
|
||||
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh
|
||||
// symlinks it to /app/conf/secrets.js). Never commit ./config/.
|
||||
// The proxy app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
|
||||
// CONF_SECRETS to point at it). Never commit ./config/.
|
||||
|
||||
module.exports = {
|
||||
oidc: {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
|
||||
// documents the shape for manual editing / reference.
|
||||
//
|
||||
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh symlinks
|
||||
// it to /app/conf/secrets.js). The app ignores the extra stack/bootstrap/
|
||||
// The SSO app reads this via @simpleworkjs/conf (docker-entrypoint.sh sets
|
||||
// CONF_SECRETS to point at it). The app ignores the extra stack/bootstrap/
|
||||
// serviceAccountPass keys (read by the orchestrator). Back this up off-host —
|
||||
// it holds all SSO secrets. Never commit ./config/.
|
||||
|
||||
@@ -17,6 +17,9 @@ module.exports = {
|
||||
bindPassword: 'CHANGE-ME', // slapd root + app bind password
|
||||
userBase: 'ou=people,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
|
||||
host: '', port: 587, secure: false,
|
||||
|
||||
+32
-1
@@ -46,6 +46,8 @@ services:
|
||||
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
||||
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
|
||||
# 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"
|
||||
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
|
||||
environment:
|
||||
@@ -54,6 +56,8 @@ services:
|
||||
# reads that are not part of its conf tree.
|
||||
- NODE_ENV=production
|
||||
- NODE_PORT=3001
|
||||
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
|
||||
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
|
||||
volumes:
|
||||
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
|
||||
# can write the generated OAuth client creds into proxy-secrets.js. The
|
||||
@@ -127,6 +131,32 @@ services:
|
||||
retries: 3
|
||||
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:-}
|
||||
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
|
||||
volumes:
|
||||
- ./config:/config:ro # jump-secrets.js (written by ensure_config/bootstrap)
|
||||
- jump-data:/var/lib/jump-host # generated host keys persist here
|
||||
|
||||
networks:
|
||||
theta-net:
|
||||
driver: bridge
|
||||
@@ -137,4 +167,5 @@ volumes:
|
||||
sso-data:
|
||||
proxy-data:
|
||||
proxy-cache:
|
||||
proxy-logs:
|
||||
proxy-logs:
|
||||
jump-data:
|
||||
@@ -31,7 +31,7 @@ fetches all three in one step; `git submodule update --remote` bumps them.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ your browser / apps / legacy LDAP clients │
|
||||
│ your browser / apps / direct LDAP clients │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ https (:443) ldaps (:636)
|
||||
┌─────────▼─────────┐
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 394 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 232 KiB |
+12
-2
@@ -15,7 +15,9 @@ LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an
|
||||
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`.
|
||||
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
|
||||
|
||||
@@ -41,9 +43,15 @@ snapshots state before every rebuild.
|
||||
- **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 legacy apps that bind directly.
|
||||
- **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
|
||||
|
||||
@@ -65,3 +73,5 @@ architecture, and running each project standalone, see the
|
||||
provider + LDAP directory this stack runs.
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
|
||||
stack runs in front of it.
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump
|
||||
host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`).
|
||||
|
||||
@@ -60,6 +60,9 @@ setups `CFG_DOMAIN` is the only value you set:
|
||||
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
|
||||
| `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
|
||||
that `./config/*.js` are operator-owned and `setup.env` is ignored. Secrets
|
||||
|
||||
Submodule
+1
Submodule jump-host added at 23d99980ce
@@ -0,0 +1 @@
|
||||
https://github.com/theta42/theta-env/pull/75
|
||||
+1
-1
Submodule proxy updated: c0e1aa666e...60dbfe5b9b
+37
-1
@@ -21,12 +21,30 @@
|
||||
# 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
|
||||
# from CFG_DOMAIN above. Uncomment and set only if your hostnames differ
|
||||
# (e.g. a different subdomain, or the domain isn't the bare apex):
|
||||
#CFG_SSO_HOST=sso.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
|
||||
@@ -36,6 +54,12 @@ CFG_DOMAIN=example.com
|
||||
#CFG_ADMIN_UID=admin # initial SSO admin username
|
||||
#CFG_ADMIN_EMAIL=admin@proxy.example.com # defaults to admin@<proxyHost>
|
||||
#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:
|
||||
#CFG_SMTP_HOST=smtp.example.com
|
||||
@@ -52,4 +76,16 @@ CFG_DOMAIN=example.com
|
||||
# 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.
|
||||
# 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"
|
||||
@@ -43,7 +43,11 @@
|
||||
# 5. docker compose exec sso-manager node /bootstrap/bootstrap.js
|
||||
# -> creates/updates the LDAP service account, first admin, OAuth client;
|
||||
# writes the OAuth client creds into ./config/proxy-secrets.js; prints
|
||||
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout.
|
||||
# CLIENT_ID / CLIENT_SECRET / ALREADY_CONFIGURED on stdout. Also seeds
|
||||
# the SSO directory with the stack's own resources (site -> host ->
|
||||
# SSO Manager + Proxy services, with the proxy's OAuth client linked
|
||||
# under its service) so the Directory page is populated out of the
|
||||
# box. Idempotent — existing slugs are operator-owned and left alone.
|
||||
# 6. docker compose up -d --build proxy; wait for /health.
|
||||
# 7. Register <SSO_HOST> and <PROXY_HOST> as Host records in the proxy (via
|
||||
# `docker compose exec proxy node`, calling the proxy's Host model
|
||||
@@ -148,6 +152,19 @@ then
|
||||
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
|
||||
|
||||
# ── 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".
|
||||
@@ -163,8 +180,11 @@ if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
|
||||
die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
|
||||
fi
|
||||
|
||||
info "Updating submodules to their latest release tag (sso-manager-node, proxy)..."
|
||||
for sm in sso-manager-node proxy; do
|
||||
# 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
|
||||
@@ -240,6 +260,8 @@ module.exports = {
|
||||
bindPassword: $(js_str "$CFG_LDAP_ADMIN_PASS"),
|
||||
userBase: $(js_str "ou=people,${dn}"),
|
||||
groupBase: $(js_str "ou=groups,${dn}"),
|
||||
ldapsHost: $(js_str "${CFG_LDAPS_HOST:-}"),
|
||||
ldapsPort: 636,
|
||||
},
|
||||
smtp: {
|
||||
host: $(js_str "${CFG_SMTP_HOST:-}"),
|
||||
@@ -259,6 +281,7 @@ module.exports = {
|
||||
stack: {
|
||||
ldapBaseDn: $(js_str "$dn"),
|
||||
ldapDomain: $(js_str "$domain"),
|
||||
siteName: $(js_str "${CFG_SITE_NAME:-local}"),
|
||||
ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"),
|
||||
ssoHost: $(js_str "$CFG_SSO_HOST"),
|
||||
proxyHost: $(js_str "$CFG_PROXY_HOST"),
|
||||
@@ -349,12 +372,14 @@ ensure_config() {
|
||||
# derivation block further down (no example.com placeholders here).
|
||||
CFG_BASE_DN="${CFG_BASE_DN:-}"
|
||||
CFG_DOMAIN="${CFG_DOMAIN:-}"
|
||||
CFG_SITE_NAME="${CFG_SITE_NAME:-}"
|
||||
CFG_ORG="${CFG_ORG:-}"
|
||||
CFG_SSO_HOST="${CFG_SSO_HOST:-}"
|
||||
CFG_PROXY_HOST="${CFG_PROXY_HOST:-}"
|
||||
CFG_ADMIN_UID="${CFG_ADMIN_UID:-}"
|
||||
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-}"
|
||||
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
|
||||
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
||||
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
|
||||
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
|
||||
CFG_LDAP_ADMIN_PASS="${CFG_LDAP_ADMIN_PASS:-}"
|
||||
@@ -383,6 +408,8 @@ ensure_config() {
|
||||
CFG_ADMIN_PASS="${BOOTSTRAP_ADMIN_PASS:-$CFG_ADMIN_PASS}"
|
||||
CFG_SVC_PASS="${LDAP_SERVICE_PASS:-$CFG_SVC_PASS}"
|
||||
CFG_LDAP_CERT_CN="${LDAP_CERT_CN:-$CFG_LDAP_CERT_CN}"
|
||||
# .env has no legacy LDAPS_HOST key; this stays as set in setup.env/env.
|
||||
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
||||
CFG_SMTP_HOST="${SMTP_HOST:-${CFG_SMTP_HOST:-}}"
|
||||
CFG_SMTP_PORT="${SMTP_PORT:-${CFG_SMTP_PORT:-}}"
|
||||
CFG_SMTP_USER="${SMTP_USER:-${CFG_SMTP_USER:-}}"
|
||||
@@ -412,10 +439,12 @@ ensure_config() {
|
||||
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
|
||||
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
|
||||
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
|
||||
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
|
||||
CFG_ORG="${CFG_ORG:-SSO Manager}"
|
||||
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
|
||||
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
|
||||
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
|
||||
CFG_LDAPS_HOST="${CFG_LDAPS_HOST:-}"
|
||||
CFG_CLIENT_ID="${CFG_CLIENT_ID:-}"
|
||||
CFG_CLIENT_SECRET="${CFG_CLIENT_SECRET:-}"
|
||||
# Random secrets (generated fresh unless sourced/migrated above). These do
|
||||
@@ -610,8 +639,6 @@ read_config_kv() {
|
||||
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
|
||||
ORG_NAME: c.name || "",
|
||||
ADMIN_UID: (c.bootstrap && c.bootstrap.adminUid) || "",
|
||||
ADMIN_PASS: (c.bootstrap && c.bootstrap.adminPass) || "",
|
||||
PROXY_LOCAL_ADMIN_PASS: (p.auth && p.auth.localAdminPass) || "",
|
||||
};
|
||||
for (const k in o) console.log(k + "=" + (o[k] == null ? "" : o[k]));
|
||||
' 2>/dev/null
|
||||
@@ -621,8 +648,6 @@ cfgval() { echo "$CFG_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
||||
SSO_HOST="$(cfgval SSO_HOST)"
|
||||
PROXY_HOST="$(cfgval PROXY_HOST)"
|
||||
ADMIN_UID="$(cfgval ADMIN_UID)"
|
||||
ADMIN_PASS="$(cfgval ADMIN_PASS)"
|
||||
PROXY_LOCAL_ADMIN_PASS="$(cfgval PROXY_LOCAL_ADMIN_PASS)"
|
||||
|
||||
info "Stack config:"
|
||||
info " SSO host: https://${SSO_HOST}"
|
||||
@@ -633,7 +658,27 @@ info " Admin uid: ${ADMIN_UID}"
|
||||
# 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.
|
||||
info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..."
|
||||
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/bootstrap.js) \
|
||||
# Host facts for the directory seed — collected HERE (on the host; inside the
|
||||
# container hostname/uname describe the container, not the machine). Same
|
||||
# collection as ldap-client/index.sh so stack hosts and ldap-client-joined
|
||||
# hosts carry identical metadata. All best-effort: a missing tool just leaves
|
||||
# the field blank.
|
||||
STACK_HOST_NAME="$(hostname 2>/dev/null || true)"
|
||||
STACK_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
_iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)"
|
||||
STACK_HOST_MAC=""
|
||||
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
|
||||
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
|
||||
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
|
||||
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}"
|
||||
|
||||
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
||||
@@ -708,6 +753,46 @@ 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
|
||||
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
|
||||
info "\033[1;32mDone. Your SSO + proxy stack is up.\033[0m"
|
||||
@@ -716,14 +801,19 @@ echo " SSO Manager UI: https://${SSO_HOST} (fronted by the proxy under TLS
|
||||
echo " first-run fallback: http://127.0.0.1:${SSO_PORT:-3001}"
|
||||
echo " Proxy mgmt UI: https://${PROXY_HOST}"
|
||||
echo " first-run fallback: http://127.0.0.1:${MGMT_PORT:-3000}"
|
||||
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 " First admin login:"
|
||||
echo " First admin login credentials are in ./config/sso-secrets.js:"
|
||||
echo " user: ${ADMIN_UID}"
|
||||
echo " pass: ${ADMIN_PASS}"
|
||||
echo " pass: bootstrap.adminPass"
|
||||
echo
|
||||
echo " Proxy local admin (anti-lockout fallback if the SSO is unreachable):"
|
||||
echo " user: proxyadmin2"
|
||||
echo " pass: ${PROXY_LOCAL_ADMIN_PASS}"
|
||||
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
|
||||
|
||||
+1
-1
Submodule sso-manager-node updated: 37f2ece172...d9a3cb6044
Reference in New Issue
Block a user