Compare commits

...

14 Commits

Author SHA1 Message Date
wmantly 5299556057 Merge pull request #91 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:44:08 -04:00
wmantly cc03b3758c Release 1.4.0: bump submodules to the unified-release tags
sso-manager-node -> v1.4.0, proxy -> v1.3.0, jump-host -> v1.2.0. The three
apps now share @simpleworkjs/{oidc-client,directory-schema,ldap,app-stack}
(1.0.0, published under the simpleworkjs org) instead of byte-identical forks,
and the SSO directory discovery API no longer leaks client_secret_hash and
returns the {results} envelope (fixing jump-host bridging). No setup.sh change:
the new deps resolve from npm in each app's image build (npm ci stays clean).
CHANGELOG embeds the full per-app release notes. UI chrome unification is
deferred to a browser-verified session (see UI_UNIFICATION_HANDOFF.md).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:43:08 -04:00
wmantly 2d0496cfda Merge pull request #90 from theta42/release/v1.3.7
Release 1.3.7
2026-07-23 21:02:46 -04:00
wmantly 48df638ddd Release 1.3.7
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:02:22 -04:00
wmantly d6d2c7144a Merge pull request #89 from theta42/feature/jump-oidc
Provision jump-host web-UI SSO login (OIDC client)
2026-07-23 21:02:06 -04:00
wmantly 67374dc914 feat: provision jump-host web-UI SSO login (OIDC client); bump to v1.1.0
The jump host's web UI now authenticates via OIDC + a local admin
(jump-host v1.1.0). Wire that in the bundle:

- bootstrap mints a dedicated 'theta-jump' OAuth client (redirect
  https://<JUMP_HOST>/api/auth/oidc/callback) and writes a full oidc
  block + generated local admin password into config/jump-secrets.js,
  mirroring the proxy's OIDC provisioning
- an existing pre-OIDC jump-secrets.js (API token but no OIDC client) is
  regenerated so upgraders get SSO login
- bump jump-host submodule v1.0.x -> v1.1.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:01:36 -04:00
wmantly a3b41c6775 Merge pull request #88 from theta42/docs/jump-host
docs: add the optional SSH jump host to the Pages site
2026-07-23 16:23:35 -04:00
wmantly b25fb56a0d docs: add the optional SSH jump host to the theta-env Pages site
- index: mention the jump host as an optional third component (intro,
  What-you-get, Related projects)
- quickstart: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT
- reframe "legacy LDAP clients" -> "direct LDAP clients" (Linux hosts are
  first-class consumers)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:17:14 -04:00
wmantly a15002b588 Merge pull request #87 from theta42/release/v1.3.6
Release 1.3.6: bump sso-manager-node to v1.3.2 (bootstrap fix)
2026-07-23 16:10:47 -04:00
wmantly fe8133b21c Release 1.3.6: bump sso-manager-node to v1.3.2
Fixes the OAuth-client-API client_id serialization bug that broke this
stack's bootstrap (rotate -> 500 -> 'bootstrap failed'). See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:10:20 -04:00
wmantly c83248e40b Merge pull request #86 from theta42/release/v1.3.5
Release 1.3.5
2026-07-23 15:58:09 -04:00
wmantly e5a5eef428 Release 1.3.5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:57:50 -04:00
wmantly d098ba7082 Merge pull request #85 from theta42/feature/jump-host
Optional SSH jump host component (theta42/jump-host)
2026-07-23 15:57:34 -04:00
wmantly 1f8f4c70be feat: optional SSH jump host component (theta42/jump-host)
Adds jump-host as a third, opt-in submodule, wired behind
CFG_JUMP_HOST_ENABLED (default off — existing installs unaffected):

- .gitmodules + jump-host submodule pinned to v1.0.0
- setup.sh: resolves the enable flag early, adds jump-host to the
  submodule tag-update loop and activates the `jump-host` compose
  profile when enabled; builds/starts the service after the proxy,
  waits for its /health, and registers its web UI as a proxy Host;
  passes CFG_JUMP_HOST_ENABLED/CFG_JUMP_HOST to the bootstrap
- docker-compose.yml: jump-host service with profiles:["jump-host"],
  depends_on sso-manager healthy, ports 2222 (SSH) + 3002 (web),
  ./config:ro + jump-data volume
- bootstrap.js: when enabled, mints a directory API token and writes
  ./config/jump-secrets.js (binds as cn=admin so it can write the
  sshPublicKey attribute for key injection), and seeds a directory
  service entry for the jump host. Warn-only, idempotent.
- setup.env.example: CFG_JUMP_HOST_ENABLED / CFG_JUMP_HOST / JUMP_SSH_PORT

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:55:40 -04:00
12 changed files with 369 additions and 14 deletions
+4
View File
@@ -4,3 +4,7 @@
[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
+90 -1
View File
@@ -10,6 +10,94 @@ for what changed inside the apps it composes.
## [Unreleased] ## [Unreleased]
## [1.4.0] - 2026-07-25
### Bumped
- sso-manager-node -> [v1.4.0](https://github.com/theta42/sso-manager-node/releases/tag/v1.4.0)
- proxy -> [v1.3.0](https://github.com/theta42/proxy/releases/tag/v1.3.0)
- jump-host -> [v1.2.0](https://github.com/theta42/jump-host/releases/tag/v1.2.0)
This release unifies the three theta42 apps onto shared `@simpleworkjs/*` packages
(`oidc-client`, `directory-schema`, `ldap`, `app-stack` — published under the
simpleworkjs org at 1.0.0), replacing each app's byte-identical forks of the same
code so they share one codebase and API schema. It also fixes a security
regression in the SSO directory discovery API (OAuth `client_secret_hash` leaked
to every authenticated caller) and the envelope drift that broke jump-host
bridging. The shared UI chrome (`top.ejs`/`bottom.ejs`, `app-base.js`, `val.js`)
is intentionally **not** unified in this release — that work is deferred to a
browser-verified session; see `UI_UNIFICATION_HANDOFF.md`. No `setup.sh` change:
the new `@simpleworkjs/*` deps resolve from npm inside each app's image build
(`npm ci` stays clean; no `file:`/`link:`).
sso-manager-node 1.4.0:
### Security
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
### Fixed
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404.
### Added
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
- `@simpleworkjs/ldap``models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
proxy 1.3.0:
### Added
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory. The per-host SSO in `routes/host_auth.js` is unchanged but consumes the shared OIDC utils.
- `@simpleworkjs/ldap` — the ldapts client + RFC 4515/4514 escaping.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/render.js` now use the shared helpers.
### Security
- **LDAP filter injection in `User.get`.** The user lookup built its search filter by interpolating `data.username` raw into `(&(objectClass=inetOrgPerson)(uid=<username>))`. A username containing `*`, `(`, `)`, `\`, or NUL could widen or alter the filter (e.g. `*` → match-all). The filter value is now passed through `escapeFilter` from `@simpleworkjs/ldap` (RFC 4515 escaping).
### Changed
- Dependency alignment: `model-redis` `^1.5``^1.6.0`, `ldapts` `^8.1.2``^8.1.8`. The four new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
jump-host 1.2.0:
### Added
- `@simpleworkjs/oidc-client` — the OIDC client (session models, auth router, OIDC utils, safe-redirect, local-admin bootstrap). Deleted the local `utils/oidc.js`, `utils/safe_redirect.js`, `models/oidc_state.js`, `models/token.js`, `models/auth.js`, `routes/auth.js`; `models/index.js` wires the factory and the local-admin bootstrap.
- `@simpleworkjs/directory-schema` — the sso↔jump-host directory contract. `utils/access.js` now fetches reachable hosts through the shared `createDirectoryClient` (`getResourcesByGroup`).
- `@simpleworkjs/ldap``models/user_ldap.js` is now a thin wrapper over `createLdapClient`, preserving this app's loose TLS default (`rejectUnauthorized: false`) and the exact export shape.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `build_info` moved from `models/` to `utils/`; `routes/render.js` uses `mountStaticModules`.
### Fixed
- **Directory envelope drift was silently treated as "no reachable hosts".** `utils/access.js` previously read `data.results || []`, so if the SSO directory ever returned a bare array (envelope drift) every per-group query collapsed to `[]` and no user could bridge. The shared client now validates the `{ results }` envelope on every call and treats an envelope violation as a failed group fetch rather than silently returning `[]`.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`, `redis` `^4.7``^6.1.0` (the direct `redis` dep is unused — only `model-redis` is used, which already brings `redis` ^6.1.0). The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps). The `/health` endpoint and footer now report `buildVersion`/`buildHash`.
- `app-base.js` `forceLogin`/`logInRedirect` switched to the `?redirect=` query-param convention (matching the server-side `/login?redirect=` route).
## [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 ## [1.3.4] - 2026-07-23
### Bumped ### Bumped
@@ -384,7 +472,8 @@ 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) - 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) - 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.20...HEAD [Unreleased]: https://github.com/theta42/theta-env/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/theta42/theta-env/compare/v1.3.7...v1.4.0
[1.1.17]: https://github.com/theta42/theta-env/compare/v1.1.16...v1.1.17 [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.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 [1.1.15]: https://github.com/theta42/theta-env/compare/v1.1.14...v1.1.15
+153 -5
View File
@@ -229,14 +229,15 @@ async function listClients(token) {
return (data && data.results) || []; 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`, { 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: CLIENT_NAME, name: o.name,
description: 'theta-env proxy (auto-registered)', description: o.description,
redirect_uris: [REDIRECT_URI], redirect_uris: o.redirect_uris,
scopes: ['openid', 'profile', 'email', 'groups'], scopes: ['openid', 'profile', 'email', 'groups'],
allowed_groups: [], allowed_groups: [],
}), }),
@@ -249,7 +250,7 @@ async function createClient(token) {
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 ${CLIENT_NAME} (${id})`); log(`Created OAuth client ${o.name} (${id})`);
return { id, secret }; return { id, secret };
} }
@@ -418,6 +419,17 @@ async function seedDirectory(token, clientId) {
subType: 'openresty', 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) // 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. // under its service, if it appears in the directory and isn't linked yet.
if (clientId) { if (clientId) {
@@ -463,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() { (async function main() {
try { try {
log(`Base DN: ${BASE_DN}`); log(`Base DN: ${BASE_DN}`);
@@ -506,6 +643,17 @@ function writeProxyCreds(id, secret) {
resolvedClientId = id; 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 // Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue. // fails the bootstrap — warn and continue.
try { try {
+28 -1
View File
@@ -131,6 +131,32 @@ 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:-}
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: networks:
theta-net: theta-net:
driver: bridge driver: bridge
@@ -141,4 +167,5 @@ volumes:
sso-data: sso-data:
proxy-data: proxy-data:
proxy-cache: proxy-cache:
proxy-logs: proxy-logs:
jump-data:
+1 -1
View File
@@ -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) │ https (:443) ldaps (:636)
┌─────────▼─────────┐ ┌─────────▼─────────┐
+10 -2
View File
@@ -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) — 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 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 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 ## Screenshots
@@ -41,7 +43,11 @@ snapshots state before every rebuild.
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups, - **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
and OAuth clients. and OAuth clients.
- **Proxy** — add the hosts you want to protect with OIDC login. - **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 - **Self-service API tokens** in both apps' UIs, for scripting/CI without a
browser session. browser session.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
@@ -67,3 +73,5 @@ architecture, and running each project standalone, see the
provider + LDAP directory this stack runs. provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this - **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it. 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`).
+3
View File
@@ -60,6 +60,9 @@ setups `CFG_DOMAIN` is the only value you set:
| `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_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
Submodule
+1
Submodule jump-host added at 67e2fc54c2
+1 -1
Submodule proxy updated: 60dbfe5b9b...f4efdfb957
+12
View File
@@ -33,6 +33,18 @@ CFG_DOMAIN=example.com
#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 # 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: # under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
#CFG_BASE_DN=dc=example,dc=com #CFG_BASE_DN=dc=example,dc=com
+65 -2
View File
@@ -152,6 +152,19 @@ then
fi fi
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 ─── # ── 1. Update submodules to their latest release tag, verify build contexts ───
# Submodules track release tags (vX.Y.Z), not the tip of master -- so # 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". # "update" means "move to the newest tag", not "move to the newest commit".
@@ -167,8 +180,11 @@ if [[ "${SKIP_SUBMODULE_UPDATE:-0}" != "1" ]]; then
die "git submodule update --init failed. Run manually: git submodule update --init --recursive" die "git submodule update --init failed. Run manually: git submodule update --init --recursive"
fi fi
info "Updating submodules to their latest release tag (sso-manager-node, proxy)..." # jump-host is optional: only track/build it when enabled.
for sm in sso-manager-node proxy; do 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 [[ -d "$sm" ]] || continue
before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)" before_rev="$(git -C "$sm" rev-parse HEAD 2>/dev/null || true)"
# Prefer the exact tag the submodule is currently pinned to; fall back # Prefer the exact tag the submodule is currently pinned to; fall back
@@ -660,6 +676,8 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
-e STACK_HOST_MAC="$STACK_HOST_MAC" \ -e STACK_HOST_MAC="$STACK_HOST_MAC" \
-e STACK_HOST_OS="$STACK_HOST_OS" \ -e STACK_HOST_OS="$STACK_HOST_OS" \
-e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \ -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) \ sso-manager node /bootstrap/bootstrap.js) \
|| die "bootstrap failed:\n${BOOTSTRAP_OUT}" || die "bootstrap failed:\n${BOOTSTRAP_OUT}"
@@ -735,6 +753,46 @@ NODEEOF
) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}" ) || die "Registering hosts with the proxy failed:\n${HOSTS_OUT}"
echo "$HOSTS_OUT" | sed 's/^/[setup] /' 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 ─────────────────────────────────────────────────────────────── # ── 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"
@@ -743,6 +801,11 @@ 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 credentials are in ./config/sso-secrets.js:"
echo " user: ${ADMIN_UID}" echo " user: ${ADMIN_UID}"