Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5299556057 | |||
| cc03b3758c | |||
| 2d0496cfda | |||
| 48df638ddd | |||
| d6d2c7144a | |||
| 67374dc914 | |||
| a3b41c6775 | |||
| b25fb56a0d | |||
| a15002b588 | |||
| fe8133b21c | |||
| c83248e40b | |||
| e5a5eef428 | |||
| d098ba7082 | |||
| 1f8f4c70be |
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+153
-5
@@ -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
@@ -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:
|
||||||
@@ -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
@@ -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`).
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
+1
-1
Submodule sso-manager-node updated: 3babf18fe4...fc0d9104d0
Reference in New Issue
Block a user