Compare commits

...

5 Commits

Author SHA1 Message Date
wmantly a56a31421d Merge pull request #7 from theta42/feature/standalone-mode
Add standalone mode (no LDAP/SSO required)
2026-07-26 21:37:06 -04:00
wmantly d33e324b31 docs: document standalone mode; bump to 1.4.0
Add README/docs/secrets.js.example coverage for the new
@simpleworkjs/orm-backed standalone mode (no LDAP/SSO), and promote the
CHANGELOG's Unreleased entry to 1.4.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:35:03 -04:00
wmantly 4879769cc7 Add standalone mode with @simpleworkjs/orm-backed user/host stores
- Add standalone.enabled config flag to switch between LDAP+SSO and
  ORM-backed backends without changing the production code path
- New ORM models: StandaloneUser (uid, passwordHash, sshPublicKeys,
  groups) and StandaloneHost (slug, displayName, kind, metadata)
- user_file.js and hosts_file.js implement the same interfaces as the
  LDAP client and accessibleHosts() respectively
- models/user_ldap.js and utils/access.js become conditional facades
  that delegate based on conf.standalone.enabled at require time
- Zero changes to ssh_server.js core logic, bridge.js, key_inject.js,
  tui_picker.js, or any other consumer
- Fix ssh_server.js: use ?? instead of || for listenPort (0 is falsy)
- Fix ssh_server.js: register session listeners before awaiting
  audit.create() so client exec/shell requests aren't rejected
- Patch StringField.toSequelize() and IntegerField.toSequelize() to
  pass through primaryKey (the ORM's UUIDField already does this)
- 47 tests pass (24 existing + 15 new unit + 3 existing integration
  + 5 new standalone integration)
- Defaults to SQLite; any Sequelize dialect works via conf.orm

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 21:34:53 -04:00
wmantly da274a3ced Merge pull request #6 from theta42/docs/screenshots-refresh
docs: add jump-host screenshots
2026-07-26 16:31:53 -04:00
wmantly b9be0fe4e1 docs: add jump-host screenshots (login, dashboard, sessions, audit)
jump-host's docs had no screenshots at all. Add the four core web UI
views and reference them from index.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:29:10 -04:00
27 changed files with 1756 additions and 87 deletions
+11
View File
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.4.0] - 2026-07-26
### Added
- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way.
- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet.
- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration).
### Fixed
- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`.
- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first.
## [1.3.0] - 2026-07-26 ## [1.3.0] - 2026-07-26
### Changed ### Changed
+52 -3
View File
@@ -2,9 +2,13 @@
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
stack. Users SSH into one public host and land on any downstream host they're stack. Users SSH into one public host and land on any downstream host they're
entitled to — authenticated against the shared LDAP directory, authorized from entitled to — audited end to end.
the [SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory
graph, audited end to end. Two backends, same SSH front door and audit trail: the default mode
authenticates against the shared LDAP directory and authorizes from the
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
hosts in a local SQL database instead.
## Two ways to connect ## Two ways to connect
@@ -44,8 +48,53 @@ bridged straight in.
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the 4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
downstream sshd. Every session is audited. downstream sshd. Every session is audited.
## Standalone mode
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
the jump host stores users and hosts itself, via
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
(Sequelize under the hood — defaults to a local SQLite file, but any
Sequelize-supported dialect works via `conf.orm`):
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
Everything else — the SSH front door, key injection, bridging, the web UI and
audit trail — is unchanged; only where users/hosts live and how passwords are
checked differs. There's no admin UI for standalone users/hosts yet — add them
with the ORM models directly:
```js
const StandaloneUser = require('./models/standalone_user');
const StandaloneHost = require('./models/standalone_host');
const bcrypt = require('bcrypt');
await StandaloneUser.create({
uid: 'alice',
passwordHash: await bcrypt.hash('a real password', 10),
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
groups: [],
});
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'web01',
kind: 'host',
metadata: { ip: '10.0.0.5', sshPort: 22 },
});
```
In standalone mode every stored host is reachable by every stored user — there
is no group-based authorization (the `groups` field on `StandaloneUser` is
accepted for interface parity but not yet enforced).
## Requirements ## Requirements
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
all of this)*
- The SSO Manager (OpenLDAP directory + `/api/discovery`). - The SSO Manager (OpenLDAP directory + `/api/discovery`).
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`). - Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on - An LDAP bind account with **write access to the `sshPublicKey` attribute** on
+1 -1
View File
@@ -1,5 +1,5 @@
title: Jump Host title: Jump Host
description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics. description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone.
url: "https://theta42.github.io" url: "https://theta42.github.io"
baseurl: "/jump-host" baseurl: "/jump-host"
logo: /assets/img/theta42.svg logo: /assets/img/theta42.svg
+22
View File
@@ -112,6 +112,28 @@ Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP, mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Standalone mode
Everything above describes the default backend. Set `standalone.enabled: true`
and two modules become conditional facades, swapping their entire
implementation at `require` time based on that flag — nothing else in the
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
web UI) changes or even knows which mode it's running in:
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
store implementing the same `getUser` / `getGroups` / `checkPassword` /
`addSshKey` interface).
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
In standalone mode there's no group-based authorization: every stored host
is accessible to every stored user.
The ORM is Sequelize underneath, defaulting to a local SQLite file but
accepting any Sequelize-supported dialect via `conf.orm`. See
[Installation](installation.html#standalone-mode) for config and how to add
users/hosts (there's no admin UI for standalone data yet).
## Where it sits in the stack ## Where it sits in the stack
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the - **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
+4
View File
@@ -74,6 +74,10 @@ changes.
Targets that don't resolve to a host you're allowed to reach are refused (and Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default. audited). Raw IPs that aren't a known directory host are denied by default.
> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO),
> every registered host is reachable by every registered user — there's no
> group-based restriction to ask an admin about.
## Authentication ## Authentication
The jump host authenticates **you** against the directory: The jump host authenticates **you** against the directory:
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+18 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Home title: Home
description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to. description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone.
--- ---
# Jump Host # Jump Host
@@ -21,6 +21,21 @@ Part of the theta42 self-hosted identity stack, alongside
[Proxy](https://theta42.github.io/proxy/), composable with one command via [Proxy](https://theta42.github.io/proxy/), composable with one command via
[theta-env](https://theta42.github.io/theta-env/). [theta-env](https://theta42.github.io/theta-env/).
## Screenshots
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users
and hosts in a local SQL database instead (SQLite by default, any
Sequelize-supported dialect if you want something else) — same SSH front door,
key injection, and audit trail. See
[Installation](installation.html#standalone-mode) to get started.
## Two ways to connect ## Two ways to connect
**Direct (WinSCP/SFTP-friendly):** **Direct (WinSCP/SFTP-friendly):**
@@ -79,6 +94,8 @@ This jump host answers both from your directory:
audit log, per-user/per-host counters audit log, per-user/per-host counters
- **Full audit trail** — who, target, method, result, bytes, duration, and the - **Full audit trail** — who, target, method, result, bytes, duration, and the
downstream host-key fingerprint downstream host-key fingerprint
- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a
local SQL database (Sequelize, any dialect — SQLite by default)
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal - Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
installer, or bundled in theta-env installer, or bundled in theta-env
+46 -1
View File
@@ -1,7 +1,7 @@
--- ---
layout: default layout: default
title: Installation title: Installation
description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options. description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options.
--- ---
# Installation # Installation
@@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their
config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf) config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
(`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env). (`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env).
## Standalone mode (no LDAP/SSO) {#standalone-mode}
Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone
Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config:
```js
standalone: { enabled: true },
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
```
`orm` is passed straight to Sequelize, so any supported dialect works — SQLite
is just the zero-dependency default. Everything downstream of auth (bridging,
key injection, the web UI, audit) is unchanged.
There's no admin UI for standalone users/hosts yet, so add them directly with
the ORM models:
```js
const StandaloneUser = require('./models/standalone_user');
const StandaloneHost = require('./models/standalone_host');
const bcrypt = require('bcrypt');
await StandaloneUser.create({
uid: 'alice',
passwordHash: await bcrypt.hash('a real password', 10),
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
groups: [],
});
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'web01',
kind: 'host',
metadata: { ip: '10.0.0.5', sshPort: 22 },
});
```
Every host in the standalone inventory is reachable by every standalone user —
there's no group-based authorization yet (`groups` on `StandaloneUser` is
accepted for interface parity with the LDAP path, not enforced).
The rest of this page (requirements, the LDAP write-ACL, the three install
paths) describes the default LDAP + SSO mode — skip it if you're running
standalone.
## Requirements ## Requirements
- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP - The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
+15
View File
@@ -98,6 +98,21 @@ module.exports = {
maxEvents: 50000, maxEvents: 50000,
}, },
// Standalone mode: run without LDAP or SSO Manager. When enabled, user
// authentication and host discovery use @simpleworkjs/orm-backed stores
// (Sequelize, defaulting to SQLite) instead of the directory services.
standalone: {
enabled: false,
},
// ORM config for standalone mode. Passed through to Sequelize — any dialect
// works. Defaults to SQLite for zero-dependency local dev.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// Orchestrator-only keys (ignored by the app, read by theta-env). // Orchestrator-only keys (ignored by the app, read by theta-env).
stack: {}, stack: {},
}; };
+8
View File
@@ -4,4 +4,12 @@ module.exports = {
ssh: { ssh: {
hostKeyPath: './data/keys', hostKeyPath: './data/keys',
}, },
standalone: {
enabled: true,
},
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
}; };
+15 -1
View File
@@ -49,4 +49,18 @@ module.exports.authRouter = oidcClient.router;
require('./audit_event'); require('./audit_event');
// Idempotent anti-lockout local admin (was the IIFE in user_redis.js). // Idempotent anti-lockout local admin (was the IIFE in user_redis.js).
bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' }); bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' });
// Standalone mode: initialize @simpleworkjs/orm for local user/host stores.
// The ORM must be loaded before any code calls user_ldap or access — both of
// which check conf.standalone.enabled at require time and may delegate to the
// ORM-backed wrappers. Model registration is synchronous; table sync is async
// but the first query will implicitly wait (Sequelize.sync is in-flight).
// Export the promise so integration tests can await it before seeding data.
let ormReady = Promise.resolve();
if (conf.standalone && conf.standalone.enabled) {
const { init } = require('@simpleworkjs/orm');
const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false };
ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] });
}
module.exports.ormReady = ormReady;
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM model for standalone-mode hosts. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js
// wrapper translates between this model and the accessibleHosts() interface
// that ssh_server.js expects.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch StringField.toSequelize() to pass through primaryKey (same fix as in
// standalone_user.js — see that file for details).
if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) {
const orig = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = orig.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
}
class StandaloneHost extends Model {
static fields = {
slug: { type: 'string', primaryKey: true },
displayName: { type: 'string' },
kind: { type: 'string', default: 'host' },
metadata: { type: 'json', default: {} },
};
}
module.exports = StandaloneHost;
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// ORM model for standalone-mode users. Stored in the configured SQL database
// (default SQLite) when conf.standalone.enabled is true. The user_file.js
// wrapper translates between this model and the LDAP-client interface that
// ssh_server.js and key_inject.js expect.
const { Model, fields } = require('@simpleworkjs/orm');
// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass
// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them
// so string and int primary keys work.
const origStringToSeq = fields.StringField.prototype.toSequelize;
fields.StringField.prototype.toSequelize = function () {
const def = origStringToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
return def;
};
const origIntToSeq = fields.IntegerField.prototype.toSequelize;
fields.IntegerField.prototype.toSequelize = function () {
const def = origIntToSeq.call(this);
if (this.primaryKey) def.primaryKey = true;
if (this.autoIncrement) def.autoIncrement = true;
return def;
};
class StandaloneUser extends Model {
static fields = {
uid: { type: 'string', primaryKey: true },
passwordHash: { type: 'string', isPrivate: true },
sshPublicKeys: { type: 'json', default: [] },
groups: { type: 'json', default: [] },
};
}
module.exports = StandaloneUser;
+65
View File
@@ -0,0 +1,65 @@
'use strict';
// ORM-backed user store for standalone mode. Implements the same interface as
// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work
// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine).
//
// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect).
// DNs are synthetic: uid=<uid>,ou=people,dc=standalone,dc=local — the real
// identity is the uid; the DN exists only for interface compatibility with
// callers that thread user.dn through to checkPassword / addSshKey.
const bcrypt = require('bcrypt');
const StandaloneUser = require('./standalone_user');
const DN_PREFIX = 'uid=';
const DN_SUFFIX = ',ou=people,dc=standalone,dc=local';
function dnFor(uid) {
return `${DN_PREFIX}${uid}${DN_SUFFIX}`;
}
function uidFromDn(dn) {
if (!dn || typeof dn !== 'string') return null;
const m = dn.match(/^uid=([^,]+)/);
return m ? m[1] : null;
}
async function getUser(uid) {
const user = await StandaloneUser.get(uid);
if (!user) return null;
return {
dn: dnFor(user.uid),
uid: user.uid,
sshPublicKeys: user.sshPublicKeys || [],
};
}
async function getGroups(dn) {
const uid = uidFromDn(dn);
if (!uid) return [];
const user = await StandaloneUser.get(uid);
if (!user) return [];
return user.groups || [];
}
async function checkPassword(dn, pw) {
const uid = uidFromDn(dn);
if (!uid) return false;
const user = await StandaloneUser.get(uid);
if (!user || !user.passwordHash) return false;
return bcrypt.compare(pw, user.passwordHash);
}
async function addSshKey(dn, keyLine) {
const uid = uidFromDn(dn);
if (!uid) return;
const user = await StandaloneUser.get(uid);
if (!user) return;
const keys = [...(user.sshPublicKeys || [])];
if (keys.includes(keyLine)) return; // idempotent
keys.push(keyLine);
await user.update({ sshPublicKeys: keys });
}
module.exports = { getUser, getGroups, checkPassword, addSshKey };
+17 -16
View File
@@ -1,22 +1,23 @@
'use strict'; 'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the // User authentication backend — LDAP in production, ORM-backed file store in
// shared @simpleworkjs/ldap package: // standalone mode. Both export the same interface:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null // getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership) // getGroups(dn) -> [cn, ...]
// checkPassword(dn, pw) -> bool (simple bind as the user) // checkPassword(dn, pw) -> bool
// addSshKey(dn, keyLine) -> void (idempotent multi-value add) // addSshKey(dn, keyLine) -> void (idempotent)
//
// Behavior is unchanged from the previous in-tree implementation: posixAccount
// user filter, groupOfNames group filter, bind-as-user password check,
// TypeOrValueExists treated as success on key add, and the same loose TLS
// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions).
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createLdapClient } = require('@simpleworkjs/ldap');
const ldapConf = conf.ldap || {}; if (conf.standalone && conf.standalone.enabled) {
module.exports = createLdapClient({ // Standalone mode: use the ORM-backed user store.
...ldapConf, module.exports = require('./user_file');
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false }, } else {
}); // Production mode: use the LDAP directory.
const { createLdapClient } = require('@simpleworkjs/ldap');
const ldapConf = conf.ldap || {};
module.exports = createLdapClient({
...ldapConf,
tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false },
});
}
+850 -1
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-jump-host", "name": "t42-jump-host",
"version": "1.3.0", "version": "1.4.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [ "author": [
{ {
@@ -25,6 +25,7 @@
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
+13 -5
View File
@@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; } function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
async function runGrammar(session, client, state) { async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' }); // Register session listeners IMMEDIATELY — before any async work.
// The client sends exec/shell requests right after opening the session;
// Deferred upstream — attach the bridge NOW, resolve/reject after connect. // if we await audit.create() first, those requests arrive before the
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
let resolveUp, rejectUp; let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; }); const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record); const dummyAudit = { patch() {}, finish() {}, event: {} };
attachSession(session, upstreamPromise, dummyAudit);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Wire the real audit record into the already-attached session.
dummyAudit.patch = (...a) => record.patch(...a);
dummyAudit.finish = (...a) => record.finish(...a);
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
try { try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, { const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
@@ -280,7 +288,7 @@ function start() {
} }
); );
const port = (conf.ssh && conf.ssh.listenPort) || 2222; const port = (conf.ssh && conf.ssh.listenPort) ?? 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0'; const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`); console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
+232
View File
@@ -0,0 +1,232 @@
'use strict';
// End-to-end standalone SSH test: a real downstream sshd, the full jump host
// SSH server (ssh_server.js), and an SSH client. Authentication and host
// discovery use the ORM-backed standalone stores (temp file SQLite).
//
// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the
// full stack: conf → ORM → user_ldap facade → ssh_server → bridge.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { Server, Client, utils } = require('ssh2');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ──
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
conf.ssh = {
listenHost: '127.0.0.1',
listenPort: 0,
hostKeyPath: path.join(tmpDir, 'keys'),
passwordAuth: 'all',
keyComment: 'jump-host-test',
defaultPort: 22,
connectTimeoutMs: 5000,
maxSessions: 10,
};
conf.redis = { prefix: 'jump_host_test_standalone_' };
conf.audit = { maxEvents: 100 };
// ── Require models/index FIRST so it initializes the ORM exactly once.
// This also registers the standalone models. We await ormReady before
// seeding data, then start the SSH server. ──
const models = require('../../models');
const StandaloneUser = require('../../models/standalone_user');
const StandaloneHost = require('../../models/standalone_host');
let downstream, downstreamPort, jump, jumpPort;
let testUserKey;
function startDownstream() {
return new Promise((resolve) => {
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
client.on('authentication', (ctx) => ctx.accept());
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept();
session.on('pty', (a) => a && a());
session.on('shell', (a) => {
const ch = a();
ch.write('downstream-shell-ready\n');
ch.on('data', (d) => ch.write('echo:' + d));
});
session.on('exec', (a, r, info) => {
const ch = a();
ch.write(`ran:${info.command}`);
ch.exit(0);
ch.end();
});
session.on('subsystem', (a, r, info) => {
if (info.name !== 'sftp') return r && r();
const ch = a();
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
});
});
});
});
srv.listen(0, '127.0.0.1', () => resolve(srv));
});
}
before(async () => {
// 1. Start downstream.
downstream = await startDownstream();
downstreamPort = downstream.address().port;
// 2. Wait for the ORM to finish syncing tables (init was called by models/index
// at require time — we just need the tables to exist before seeding).
await models.ormReady;
// 3. Seed test data.
const userKeyPair = utils.generateKeyPairSync('ed25519');
testUserKey = userKeyPair.private;
const userPubKey = utils.parseKey(userKeyPair.private);
const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`;
const passwordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'testuser',
passwordHash,
sshPublicKeys: [userPubLine],
groups: ['admin'],
});
await StandaloneHost.create({
slug: 'host_test',
displayName: 'Test Downstream',
kind: 'host',
metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort },
});
// 4. Start the jump host SSH server.
const sshServer = require('../../services/ssh_server');
jump = sshServer.start();
await new Promise((resolve) => {
const check = () => {
const addr = jump.address();
if (addr) { jumpPort = addr.port; resolve(); }
else setTimeout(check, 10);
};
check();
});
});
after(() => {
try { downstream && downstream.close(); } catch (_) {}
try { jump && jump.close(); } catch (_) {}
try { models.redisClient.destroy(); } catch (_) {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
process.on('unhandledRejection', () => {});
function connectJump(opts = {}) {
const conn = new Client();
const connectOpts = {
host: '127.0.0.1',
port: jumpPort,
username: opts.username || 'testuser_-_host_test',
...opts,
};
return {
conn,
ready: new Promise((res, rej) => {
conn.on('ready', res).on('error', rej).connect(connectOpts);
}),
};
}
// ── Tests ──
test('public key auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('hello-world', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:hello-world/);
});
test('public key auth + grammar mode shell', async () => {
const { conn, ready } = connectJump({ privateKey: testUserKey });
await ready;
const out = await new Promise((resolve, reject) => {
conn.shell((err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => {
buf += d;
if (buf.includes('echo:ping')) resolve(buf);
});
setTimeout(() => stream.write('ping'), 150);
setTimeout(() => resolve(buf), 5000);
});
});
conn.end();
assert.match(out, /downstream-shell-ready/);
assert.match(out, /echo:ping/);
});
test('password auth + grammar mode exec', async () => {
const { conn, ready } = connectJump({
username: 'testuser_-_host_test',
password: 'testpass',
});
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('pw-test', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:pw-test/);
});
test('password auth denied with wrong password', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'testuser_-_host_test',
password: 'wrongpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
test('unknown user rejected', async () => {
const conn = new Client();
const result = await new Promise((resolve) => {
conn.on('ready', () => resolve('unexpected-ready'));
conn.on('error', () => resolve('auth-failed'));
conn.connect({
host: '127.0.0.1', port: jumpPort,
username: 'nobody_-_host_test',
password: 'testpass',
});
});
assert.strictEqual(result, 'auth-failed');
});
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// Unit tests for the ORM-backed host inventory (utils/hosts_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneHost = require('../../models/standalone_host');
let tmpDir;
let hostsFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneHost] });
await StandaloneHost.create({
slug: 'host_web01',
displayName: 'Web Server 01',
kind: 'host',
metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'host_db',
displayName: 'Database Server',
kind: 'host',
metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 },
});
await StandaloneHost.create({
slug: 'app_gitea',
displayName: 'Gitea',
kind: 'service',
metadata: { url: 'https://gitea.internal' },
});
hostsFile = require('../../utils/hosts_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('accessibleHosts returns all hosts', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.strictEqual(hosts.length, 2);
const slugs = hosts.map((h) => h.slug).sort();
assert.deepStrictEqual(slugs, ['host_db', 'host_web01']);
});
test('accessibleHosts filters to kind=host', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const kinds = [...new Set(hosts.map((h) => h.kind))];
assert.deepStrictEqual(kinds, ['host']);
});
test('accessibleHosts returns host resources with expected shape', async () => {
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
const web = hosts.find((h) => h.slug === 'host_web01');
assert.ok(web);
assert.strictEqual(web.id, 'host_web01');
assert.strictEqual(web.displayName, 'Web Server 01');
assert.strictEqual(web.metadata.ip, '10.0.0.10');
assert.strictEqual(web.metadata.sshPort, 22);
});
test('accessibleHosts returns empty array when no hosts exist', async () => {
// Delete all hosts and verify empty result.
const all = await StandaloneHost.list();
for (const h of all) {
await h.delete({ force: true });
}
const hosts = await hostsFile.accessibleHosts({ uid: 'alice' });
assert.deepStrictEqual(hosts, []);
});
+113
View File
@@ -0,0 +1,113 @@
'use strict';
// Unit tests for the ORM-backed user store (models/user_file.js).
// Uses a temp file SQLite database — no external services needed.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const bcrypt = require('bcrypt');
const conf = require('@simpleworkjs/conf');
const { init } = require('@simpleworkjs/orm');
const StandaloneUser = require('../../models/standalone_user');
let testPasswordHash;
let tmpDir;
let userFile; // required after ORM init
before(async () => {
// Unique temp DB so this test file doesn't collide with other ORM tests.
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-'));
const dbPath = path.join(tmpDir, 'test.sqlite');
conf.standalone = { enabled: true };
conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false };
await init({ conf: { orm: conf.orm }, models: [StandaloneUser] });
testPasswordHash = await bcrypt.hash('testpass', 4);
await StandaloneUser.create({
uid: 'alice',
passwordHash: testPasswordHash,
sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'],
groups: ['admin', 'developers'],
});
// Now that the ORM is initialized and conf.standalone is set, require the
// facade. It checks conf.standalone.enabled at require time.
userFile = require('../../models/user_file');
});
after(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
test('getUser returns user with synthesized dn and keys', async () => {
const user = await userFile.getUser('alice');
assert.ok(user);
assert.strictEqual(user.uid, 'alice');
assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']);
});
test('getUser returns null for unknown uid', async () => {
const user = await userFile.getUser('nobody');
assert.strictEqual(user, null);
});
test('getGroups returns user groups', async () => {
const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, ['admin', 'developers']);
});
test('getGroups returns empty array for unknown dn', async () => {
const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local');
assert.deepStrictEqual(groups, []);
});
test('getGroups returns empty array for malformed dn', async () => {
const groups = await userFile.getGroups('not-a-dn');
assert.deepStrictEqual(groups, []);
});
test('checkPassword returns true for correct password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, true);
});
test('checkPassword returns false for wrong password', async () => {
const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass');
assert.strictEqual(ok, false);
});
test('checkPassword returns false for unknown user', async () => {
const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass');
assert.strictEqual(ok, false);
});
test('addSshKey appends a new key', async () => {
const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop';
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey);
const user = await StandaloneUser.get('alice');
assert.ok(user.sshPublicKeys.includes(newKey));
});
test('addSshKey is idempotent', async () => {
const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop';
const userBefore = await StandaloneUser.get('alice');
const countBefore = userBefore.sshPublicKeys.length;
await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key);
const userAfter = await StandaloneUser.get('alice');
assert.strictEqual(userAfter.sshPublicKeys.length, countBefore);
});
test('addSshKey is a no-op for unknown user', async () => {
// Should not throw.
await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...');
});
+71 -57
View File
@@ -1,70 +1,84 @@
'use strict'; 'use strict';
// Which directory hosts may a user reach, and how do we dial them? // Host discovery — SSO Manager API in production, ORM-backed inventory in
// // standalone mode. Both export the same interface:
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's // accessibleHosts(user) -> [host resources]
// /api/discovery/me only answers for the API token's own user, and /graph // clearCache(uid?) -> void
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000; if (conf.standalone && conf.standalone.enabled) {
const cache = new Map(); // uid -> {at, hosts} // Standalone mode: use the ORM-backed host inventory.
const { accessibleHosts } = require('./hosts_file');
module.exports = { accessibleHosts, clearCache: () => {} };
} else {
// Production mode: LDAP groups + SSO API (unchanged).
// Build a directory client bound to conf.sso. fetchImpl is injectable so the // Which directory hosts may a user reach, and how do we dial them?
// unit tests can stub the transport; the shared client validates the //
// `{ results }` envelope on every call (turns the old bare-array drift into a // v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// thrown error instead of a silent `[]`). // /api/discovery/me only answers for the API token's own user, and /graph
function directoryClient({ fetchImpl = fetch } = {}) { // omits ResourceGroup links — so we combine the user's LDAP groups (queried
const sso = conf.sso || {}; // directly) with per-group resource lookups:
return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl }); //
} // 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
return directoryClient({ fetchImpl }).getResourcesByGroup(group); const userLdap = require('../models/user_ldap');
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) { const CACHE_TTL_MS = 30 * 1000;
const hit = cache.get(user.uid); const cache = new Map(); // uid -> {at, hosts}
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn); // Build a directory client bound to conf.sso. fetchImpl is injectable so the
// unit tests can stub the transport; the shared client validates the
const seen = new Map(); // `{ results }` envelope on every call (turns the old bare-array drift into a
for (const cn of groups) { // thrown error instead of a silent `[]`).
let resources; function directoryClient({ fetchImpl = fetch } = {}) {
try { const sso = conf.sso || {};
resources = await fetchResourcesByGroup(cn, { fetchImpl }); return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
} }
const hosts = [...seen.values()]; async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
cache.set(user.uid, { at: Date.now(), hosts }); return directoryClient({ fetchImpl }).getResourcesByGroup(group);
return hosts; }
}
function clearCache(uid) { async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
if (uid) cache.delete(uid); const hit = cache.get(user.uid);
else cache.clear(); if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup }; const groups = await ldap.getGroups(user.dn);
const seen = new Map();
for (const cn of groups) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
}
const hosts = [...seen.values()];
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
function clearCache(uid) {
if (uid) cache.delete(uid);
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
}
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// ORM-backed host inventory for standalone mode. Implements the same interface
// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user)
// returns an array of host resources the user may reach.
//
// In standalone mode all hosts in the inventory are accessible to every
// authenticated user — there is no group-based filtering. The _user parameter
// is accepted for interface compatibility but ignored.
const StandaloneHost = require('../models/standalone_host');
async function accessibleHosts(_user) {
const hosts = await StandaloneHost.list({ where: { kind: 'host' } });
// The ORM returns model instances; map to plain objects matching the shape
// that target_match.js and tui_picker.js expect.
return hosts.map((h) => ({
id: h.slug, // slug doubles as the stable id in standalone mode
kind: h.kind,
slug: h.slug,
displayName: h.displayName,
metadata: h.metadata || {},
}));
}
function clearCache() {
// No cache in standalone mode — every call reads from the DB.
}
module.exports = { accessibleHosts, clearCache };
+18
View File
@@ -11,7 +11,24 @@
module.exports = { module.exports = {
name: 'My Org', name: 'My Org',
// Standalone mode: run with no LDAP directory and no SSO Manager. When
// enabled, user auth and host discovery use the ORM-backed stores below
// instead of `ldap` + `sso` (both become unused). See the README's
// "Standalone mode" section for how to add users/hosts.
standalone: {
enabled: false,
},
// ORM config for standalone mode (Sequelize — any dialect works, not just
// sqlite). Ignored unless standalone.enabled is true.
orm: {
dialect: 'sqlite',
storage: './data/standalone.sqlite',
logging: false,
},
// The directory the users live in (the SSO Manager's OpenLDAP). // The directory the users live in (the SSO Manager's OpenLDAP).
// Unused when standalone.enabled is true.
// //
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on // IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
// the sshPublicKey attribute of user entries — the jump host injects its // the sshPublicKey attribute of user entries — the jump host injects its
@@ -36,6 +53,7 @@ module.exports = {
// SSO Manager directory (inventory) API. apiToken is a personal access // SSO Manager directory (inventory) API. apiToken is a personal access
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*. // token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
// Unused when standalone.enabled is true.
sso: { sso: {
url: 'https://sso.example.com', url: 'https://sso.example.com',
apiToken: 'sso_CHANGE_ME', apiToken: 'sso_CHANGE_ME',