feat: initial jump-host — SSH jump host for the theta42 stack

An SSH jump host that authenticates users against the shared LDAP
directory, authorizes them from the SSO Manager's inventory graph, and
bridges them to downstream hosts — auditing everything.

- Username-grammar routing (uid_-_target@jump) + interactive TUI picker
- Inbound LDAP auth (publickey / password with off|local|all policy)
- Directory-driven access (LDAP groups x /api/discovery/resources?group=)
- Per-user key injection into sshPublicKey, connects downstream as the user
- Shell / exec / SFTP-subsystem bridging (WinSCP works)
- Web UI + HTTP API (:3002) for audit + metrics; LDAP-admin gated
- Packaged like proxy: ops/install.sh + systemd, all-in-one Docker, compose
- Tests: 23 unit + 3 integration (node --test), all green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:46:19 -04:00
commit 36e9d5b0b3
51 changed files with 4291 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
'use strict';
// Audit trail of every connection attempt/session. Stored as one redis hash
// per event plus a sorted-set index (score = timestamp) for paged reads,
// trimmed to conf.audit.maxEvents. Raw redis (not the Table model) because we
// want the zset index and cheap range reads.
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}audit_index`;
const evtKey = (id) => `${P()}audit_${id}`;
// A live event: create() returns a handle you finish() when the session ends.
async function create(fields) {
const id = crypto.randomUUID();
const ts = Date.now();
const event = {
id, ts,
uid: '', authMethod: '', mode: '',
targetSlug: '', targetAddr: '', targetPort: '',
channel: '', clientIp: '',
success: false, failReason: '',
hostKeyFp: '', startedAt: ts, endedAt: '', durationMs: '',
bytesIn: 0, bytesOut: 0,
...fields,
};
await write(event);
return {
id,
event,
async patch(update) {
Object.assign(event, update);
await write(event);
},
async finish(update = {}) {
Object.assign(event, update, {
endedAt: Date.now(),
durationMs: Date.now() - event.startedAt,
});
await write(event);
},
};
}
async function write(event) {
const redis = await getRedis();
await redis.hSet(evtKey(event.id), serialize(event));
await redis.zAdd(idxKey(), { score: event.ts, value: event.id });
// Trim oldest beyond the cap.
const max = (conf.audit && conf.audit.maxEvents) || 50000;
const count = await redis.zCard(idxKey());
if (count > max) {
const stale = await redis.zRange(idxKey(), 0, count - max - 1);
if (stale.length) {
await redis.zRem(idxKey(), stale);
await redis.del(stale.map(evtKey));
}
}
}
function serialize(event) {
const out = {};
for (const [k, v] of Object.entries(event)) {
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return {
...h,
ts: Number(h.ts),
success: h.success === '1',
bytesIn: Number(h.bytesIn || 0),
bytesOut: Number(h.bytesOut || 0),
durationMs: h.durationMs === '' ? null : Number(h.durationMs),
};
}
// Newest-first paged read with optional filters.
async function list({ page = 0, pageSize = 50, uid, target, status } = {}) {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
const events = [];
for (const id of ids) {
const e = deserialize(await redis.hGetAll(evtKey(id)));
if (!e) continue;
if (uid && e.uid !== uid) continue;
if (target && e.targetSlug !== target && e.targetAddr !== target) continue;
if (status === 'success' && !e.success) continue;
if (status === 'fail' && e.success) continue;
events.push(e);
}
const start = page * pageSize;
return { total: events.length, page, pageSize, results: events.slice(start, start + pageSize) };
}
module.exports = { create, list };
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// Short git commit, baked into /app/.build_commit at image build time (see
// Dockerfile gitinfo stage) or resolved from git on bare metal.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function resolve() {
try {
const baked = path.join(__dirname, '../../.build_commit');
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch (_) {}
return 'unknown';
}
let version = 'unknown';
try { version = require('../package.json').version; } catch (_) {}
module.exports = { commit: resolve(), version };
+35
View File
@@ -0,0 +1,35 @@
'use strict';
// model-redis backing (same store the other stack apps use). Table is the
// base class; getRedis() exposes the underlying node-redis client for the
// counters and sorted-set index in models/metrics.js and models/audit_event.js.
const conf = require('@simpleworkjs/conf');
const { setUpTable } = require('model-redis');
const Table = setUpTable(conf.redis);
module.exports = Table;
// The raw node-redis client (created + connecting inside model-redis) — used
// for the INCR counters and the sorted-set audit index. model-redis connects
// it asynchronously; ensure it's open before first use.
let readyPromise;
async function getRedis() {
const client = Table.redisClient;
if (!readyPromise) {
readyPromise = (async () => {
if (!client.isOpen) {
try { await client.connect(); } catch (_) { /* already connecting */ }
}
return client;
})();
}
await readyPromise;
return client;
}
module.exports.getRedis = getRedis;
require('./session');
require('./audit_event');
+40
View File
@@ -0,0 +1,40 @@
'use strict';
// Cheap counters for the dashboard. redis INCR — no history, just totals.
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => `${conf.redis.prefix}m_`;
async function bump({ uid, hostSlug, success }) {
const redis = await getRedis();
const day = new Date().toISOString().slice(0, 10);
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
if (!success) ops.push(redis.incr(`${P()}fail`));
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
await Promise.all(ops);
}
async function summary() {
const redis = await getRedis();
const [total, fail] = await Promise.all([
redis.get(`${P()}total`),
redis.get(`${P()}fail`),
]);
const userKeys = await redis.keys(`${P()}user_*`);
const hostKeys = await redis.keys(`${P()}host_*`);
const topN = async (keys, strip) => {
const entries = await Promise.all(keys.map(async (k) => [k.slice(strip.length), Number(await redis.get(k))]));
return entries.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count }));
};
return {
total: Number(total || 0),
fail: Number(fail || 0),
topUsers: await topN(userKeys, `${P()}user_`),
topHosts: await topN(hostKeys, `${P()}host_`),
};
}
module.exports = { bump, summary };
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Web UI sessions — a signed-in admin's browser token. model-redis Table with
// a TTL so entries expire and survive restarts.
const crypto = require('crypto');
const Table = require('.');
class Session extends Table {
static _key = 'token';
static _keyMap = {
'token': {default: function(){ return crypto.randomUUID() }, type: 'string'},
'uid': {isRequired: true, type: 'string'},
'groups': {default: '[]', type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'expires_at': {default: 0, type: 'number'},
}
}
Session.register();
Session.start = async function (uid, groups, ttlMs) {
return Session.create({
uid,
groups: JSON.stringify(groups || []),
expires_at: Date.now() + ttlMs,
}, { ttl: Math.ceil(ttlMs / 1000) });
};
Session.verify = async function (token) {
if (!token) return null;
let session;
try {
session = await Session.get(token);
} catch (_) {
return null;
}
if (!session || session.expires_at < Date.now()) return null;
return session;
};
module.exports = Session;
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
// checkPassword(dn, pw) -> bool (simple bind as the user)
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
//
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
// TypeOrValueExists treated as success on key add).
const { Client, Change, Attribute } = require('ldapts');
const conf = require('@simpleworkjs/conf');
function ldapConf() {
return conf.ldap || {};
}
function makeClient() {
const c = ldapConf();
return new Client({
url: c.url,
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
});
}
// Escape a value being interpolated into an LDAP filter (RFC 4515).
function escapeFilter(value) {
return String(value).replace(/[\\*()\0]/g, (ch) => ({
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
}[ch]));
}
async function withClient(fn) {
const c = ldapConf();
const client = makeClient();
try {
await client.bind(c.bindDN, c.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
async function getUser(uid) {
const c = ldapConf();
const attr = c.userNameAttribute || 'uid';
return withClient(async (client) => {
const { searchEntries } = await client.search(c.userBase, {
scope: 'sub',
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
});
if (!searchEntries.length) return null;
const e = searchEntries[0];
let keys = e.sshPublicKey || [];
if (!Array.isArray(keys)) keys = [keys];
return {
dn: e.dn,
uid: String(e[attr]),
sshPublicKeys: keys.map(String),
};
});
}
async function getGroups(dn) {
const c = ldapConf();
return withClient(async (client) => {
const { searchEntries } = await client.search(c.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
attributes: ['cn'],
});
return searchEntries.map((e) => String(e.cn));
});
}
async function checkPassword(dn, password) {
if (!password) return false;
const client = makeClient();
try {
await client.bind(dn, password);
return true;
} catch (_) {
return false;
} finally {
await client.unbind().catch(() => {});
}
}
async function addSshKey(dn, keyLine) {
return withClient(async (client) => {
try {
await client.modify(dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
}),
]);
} catch (error) {
// Same de-dup semantics as the SSO's User.addSSHkey.
if (error.name === 'TypeOrValueExistsError') return;
throw error;
}
});
}
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };