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
+150
View File
@@ -0,0 +1,150 @@
'use strict';
// Bridge one authenticated inbound SSH session to a downstream host: open an
// ssh2.Client to the target as the real user (jump host's own private key —
// already injected into the user's sshPublicKey), then splice each inbound
// channel (shell / exec / sftp subsystem) to a matching upstream channel.
const { Client } = require('ssh2');
const crypto = require('crypto');
const { Transform } = require('stream');
const conf = require('@simpleworkjs/conf');
const registry = require('./session_registry');
const metrics = require('../models/metrics');
const { clearInjectedFlag } = require('../utils/key_inject');
// A pass-through that tallies bytes (cheap; one per direction per channel).
function counter(onBytes) {
return new Transform({
transform(chunk, _enc, cb) { onBytes(chunk.length); cb(null, chunk); },
});
}
// Connect the upstream ssh2.Client, retrying once after a short pause if the
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
// first-time key injection).
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
return new Promise((resolve, reject) => {
let attempted = false;
const dial = (allowRetry) => {
const client = new Client();
client
.on('ready', () => resolve(client))
.on('error', async (err) => {
const authish = /authentication|All configured authentication methods failed/i.test(err.message || '');
if (authish && allowRetry) {
attempted = true;
await clearInjectedFlag(uid).catch(() => {});
setTimeout(() => dial(false), 2000);
return;
}
reject(err);
})
.connect({
host, port, username, privateKey,
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
keepaliveInterval: 15000,
hostVerifier: (key) => {
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
if (onHostKey) onHostKey(fp);
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
},
});
};
dial(justInjected); // only bother retrying if we just wrote the key
});
}
// Wire an inbound session (the ssh2 Server 'session' accept() result) to the
// upstream client. Session handlers are attached SYNCHRONOUSLY (call this the
// moment the session is accepted) so channel requests the client sends before
// the upstream connection is ready aren't auto-rejected: pty/env/window-change
// are buffered, and shell/exec/subsystem accept the inbound channel then wait
// on `upstreamPromise` before opening the matching upstream channel.
//
// upstreamPromise resolves to the ready ssh2.Client, or rejects (target
// unreachable) — in which case pending channels get a friendly message.
function attachSession(session, upstreamPromise, audit) {
let ptyInfo = null;
const env = {};
let bytesIn = 0, bytesOut = 0;
let upstreamStream = null;
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('env', (accept, _reject, info) => { env[info.key] = info.val; accept && accept(); });
session.on('window-change', (accept, _reject, info) => {
if (upstreamStream) upstreamStream.setWindow(info.rows, info.cols, info.height, info.width);
accept && accept();
});
const pipeStreams = (inbound, up, channel) => {
audit.patch({ channel });
upstreamStream = up;
up.pipe(counter((n) => { bytesOut += n; })).pipe(inbound);
inbound.pipe(counter((n) => { bytesIn += n; })).pipe(up);
up.on('exit', (code, signal) => {
if (!signal && inbound.exit) inbound.exit(code == null ? 0 : code);
});
up.on('close', () => { audit.event.bytesIn = bytesIn; audit.event.bytesOut = bytesOut; inbound.close && inbound.close(); });
inbound.on('close', () => { up.end && up.end(); });
};
const withUpstream = (inbound, open) => {
upstreamPromise.then((up) => open(up)).catch((err) => {
try { inbound.stderr && inbound.stderr.write(`jump-host: ${err.message}\r\n`); } catch (_) {}
try { inbound.exit && inbound.exit(1); inbound.close(); } catch (_) {}
});
};
session.on('shell', (accept) => {
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.shell(ptyInfo || false, { env }, (err, up) => {
if (err) { try { inbound.stderr.write(`jump-host: upstream shell failed: ${err.message}\r\n`); inbound.exit(1); inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'shell');
});
});
});
session.on('exec', (accept, _reject, info) => {
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.exec(info.command, { pty: ptyInfo || undefined, env }, (err, up) => {
if (err) { try { inbound.stderr.write(`jump-host: upstream exec failed: ${err.message}\r\n`); inbound.exit(1); inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'exec');
});
});
});
session.on('subsystem', (accept, reject, info) => {
if (info.name !== 'sftp') return reject && reject();
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.subsys('sftp', (err, up) => {
if (err) { try { inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'sftp');
});
});
});
}
// TUI mode: the picker already opened one inbound shell channel. Bridge THAT
// channel directly to an upstream shell (no waiting for further channel
// requests). window-change from the client is forwarded via the session.
function bridgeShellChannel(inbound, upstream, ptyInfo, audit) {
return new Promise((resolve, reject) => {
upstream.shell(ptyInfo || false, {}, (err, up) => {
if (err) return reject(err);
audit.patch({ channel: 'shell' });
let bytesIn = 0, bytesOut = 0;
up.pipe(counter((n) => { bytesOut += n; })).pipe(inbound);
inbound.pipe(counter((n) => { bytesIn += n; })).pipe(up);
up.on('exit', (code) => { try { inbound.exit(code == null ? 0 : code); } catch (_) {} });
up.on('close', () => { audit.event.bytesIn = bytesIn; audit.event.bytesOut = bytesOut; try { inbound.close(); } catch (_) {} });
inbound.on('close', () => { try { up.end(); } catch (_) {} });
resolve({ upstreamStream: up, counters: () => ({ bytesIn, bytesOut }) });
});
});
}
module.exports = { connectUpstream, attachSession, bridgeShellChannel, counter, registry, metrics };
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// In-memory registry of live SSH sessions — feeds GET /api/sessions and the
// maxSessions cap. Ephemeral by design (a restart drops every bridge anyway).
const sessions = new Map(); // id -> descriptor
function add(id, desc) {
sessions.set(id, { id, startedAt: Date.now(), ...desc });
}
function remove(id) {
sessions.delete(id);
}
function list() {
return [...sessions.values()];
}
function count() {
return sessions.size;
}
module.exports = { add, remove, list, count };
+291
View File
@@ -0,0 +1,291 @@
'use strict';
// The public SSH front door. Authenticates the inbound user against LDAP,
// parses the username grammar, resolves the target from the directory (or runs
// the TUI picker), injects the jump host's key for the user, bridges to the
// downstream host, and audits everything.
const { Server, utils: { parseKey } } = require('ssh2');
const conf = require('@simpleworkjs/conf');
const { ensureKeys } = require('../utils/host_keys');
const { parseUsername } = require('../utils/username_grammar');
const { matchTarget, hostEndpoint } = require('../utils/target_match');
const { accessibleHosts } = require('../utils/access');
const { ensureKeyInjected } = require('../utils/key_inject');
const userLdap = require('../models/user_ldap');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('./session_registry');
const { pickHost } = require('./tui_picker');
const { connectUpstream, attachSession, bridgeShellChannel } = require('./bridge');
let JUMP_KEYS; // { hostKeys, clientKey, publicLine }
// Is a client address "local" (loopback or RFC1918)? Governs passwordAuth:'local'.
function isLocalAddr(ip) {
if (!ip) return false;
const a = ip.replace(/^::ffff:/, '');
return a === '127.0.0.1' || a === '::1'
|| /^10\./.test(a) || /^192\.168\./.test(a)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(a);
}
function passwordAllowed(clientIp) {
const mode = (conf.ssh && conf.ssh.passwordAuth) || 'local';
if (mode === 'all') return true;
if (mode === 'off') return false;
return isLocalAddr(clientIp); // 'local'
}
// Compare an inbound publickey to the user's LDAP keys, EXCLUDING the jump
// host's own injected key (only the jump host may hold that private half).
function userKeyMatches(user, ctxKey) {
const marker = conf.ssh.keyComment;
for (const line of user.sshPublicKeys || []) {
if (marker && line.trim().endsWith(marker)) continue;
const parsed = parseKey(line);
if (parsed instanceof Error) continue;
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key.type === ctxKey.algo && key.getPublicSSH().equals(ctxKey.data)) return key;
}
return null;
}
function handleAuth(ctx, state) {
(async () => {
let parsed;
try {
parsed = parseUsername(ctx.username);
} catch (_) {
return ctx.reject(['publickey', 'password']);
}
state.uid = parsed.uid;
state.target = parsed.target;
const user = await userLdap.getUser(parsed.uid).catch(() => null);
if (!user) return ctx.reject(['publickey', 'password']);
state.user = user;
if (ctx.method === 'publickey') {
const key = userKeyMatches(user, ctx.key);
if (!key) return ctx.reject(['publickey', 'password']);
// Two-phase: probe (no signature) then verify.
if (ctx.signature) {
const ok = key.verify(ctx.blob, ctx.signature, ctx.hashAlgo);
if (ok !== true) return ctx.reject();
}
state.authMethod = 'publickey';
return ctx.accept();
}
if (ctx.method === 'password') {
if (!passwordAllowed(state.clientIp)) return ctx.reject(['publickey']);
const ok = await userLdap.checkPassword(user.dn, ctx.password);
if (!ok) return ctx.reject(['publickey', 'password']);
state.authMethod = 'password';
return ctx.accept();
}
return ctx.reject(['publickey', 'password']);
})().catch(() => ctx.reject());
}
// After auth: resolve target (grammar or TUI), inject key, bridge.
async function onReady(client, state) {
if (registry.count() >= ((conf.ssh && conf.ssh.maxSessions) || 100)) {
client.end();
return;
}
client.once('session', (accept) => {
const session = accept();
runSession(session, client, state).catch(() => {
try { client.end(); } catch (_) {}
});
});
}
async function runSession(session, client, state) {
// Grammar mode: the client opens its own channels (shell/exec/sftp) right
// after the session — attach the buffering bridge SYNCHRONOUSLY so no
// channel request is dropped while we resolve+connect asynchronously.
if (state.target) return runGrammar(session, client, state);
return runTuiSession(session, client, state);
}
// Shared: resolve target -> inject key -> connect upstream. Returns
// { upstream, host, endpoint, record } or throws { reason }.
async function resolveAndConnect(state, record, { onHostKey } = {}) {
const hosts = await accessibleHosts(state.user).catch(() => { throw fail('directory-unreachable'); });
let host = null, raw = null;
const m = matchTarget(state.target, hosts, { allowRawIPs: conf.ssh.allowRawIPs });
host = m.host; raw = m.raw;
const endpoint = host ? hostEndpoint(host, conf.ssh.defaultPort) : { address: raw, port: conf.ssh.defaultPort };
if (!endpoint.address) throw fail('no-address');
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { throw fail('key-inject-failed'); }
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey,
});
} catch (_) { throw fail('upstream-unreachable'); }
return { upstream, host, endpoint };
}
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record);
try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
});
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: host ? host.slug : 'raw-ip' });
await record.patch({ success: true });
await metrics.bump({ uid: state.uid, hostSlug: host ? host.slug : undefined, success: true });
resolveUp(upstream);
wireTeardown(session, client, upstream, record);
} catch (err) {
const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
}
}
async function runTuiSession(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason) => {
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
try { client.end(); } catch (_) {}
};
let hosts;
try { hosts = await accessibleHosts(state.user); }
catch (_) { return finishFail('directory-unreachable'); }
const tui = await runTui(session, state.uid, hosts);
if (!tui.host) return finishFail('cancelled');
state.target = tui.host.slug;
const endpoint = hostEndpoint(tui.host, conf.ssh.defaultPort);
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { return finishFail('key-inject-failed'); }
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
});
} catch (_) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
return finishFail('upstream-unreachable');
}
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
await record.patch({ success: true });
await metrics.bump({ uid: state.uid, hostSlug: tui.host.slug, success: true });
let upstreamStream;
session.on('window-change', (accept, _reject, info) => {
if (upstreamStream) upstreamStream.setWindow(info.rows, info.cols, info.height, info.width);
accept && accept();
});
try {
const r = await bridgeShellChannel(tui.channel, upstream, tui.ptyInfo, record);
upstreamStream = r.upstreamStream;
} catch (err) {
try { tui.channel.write(`\r\n Upstream shell failed: ${err.message}\r\n`); tui.channel.close(); } catch (_) {}
}
wireTeardown(session, client, upstream, record);
}
function wireTeardown(session, client, upstream, record) {
upstream.on('close', async () => {
registry.remove(record.id);
await record.finish({ success: true });
});
client.on('close', () => { try { upstream.end(); } catch (_) {} });
}
function reasonMessage(reason) {
return {
'no-such-target': 'no host you can access matches that target',
'no-access': 'you do not have access to that host',
'directory-unreachable': 'directory service unavailable',
'upstream-unreachable': 'could not reach the target host',
'key-inject-failed': 'could not provision your access key',
}[reason] || reason;
}
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
// host is null if the user quit. exec/subsystem in picker mode are rejected.
function runTui(session, uid, hosts) {
return new Promise((resolve) => {
let ptyInfo = null;
let settled = false;
const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => {
const channel = accept();
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
});
});
session.on('exec', (accept) => {
const c = accept();
try { c.stderr.write('jump-host: interactive login required to pick a host (or use uid_-_target)\r\n'); c.exit(1); c.close(); } catch (_) {}
finish({ host: null });
});
session.on('subsystem', (accept, reject) => { reject && reject(); finish({ host: null }); });
});
}
function start() {
JUMP_KEYS = ensureKeys();
const server = new Server(
{ hostKeys: JUMP_KEYS.hostKeys, banner: (conf.ssh && conf.ssh.banner) || undefined },
(client, info) => {
const state = { clientIp: (info && info.ip) || null };
client.on('authentication', (ctx) => handleAuth(ctx, state));
client.on('ready', () => onReady(client, state));
client.on('error', () => {});
}
);
const port = (conf.ssh && conf.ssh.listenPort) || 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
});
return server;
}
module.exports = { start, _internal: { isLocalAddr, passwordAllowed, userKeyMatches } };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
// Hand-rolled ANSI host picker rendered over an inbound SSH shell channel.
// (blessed/inquirer/ink want a real TTY object; an ssh2 server channel isn't
// one, so we parse raw keystrokes ourselves.) Resolves to the chosen host
// resource, or null if the user quits.
const ESC = '\x1b';
const CLEAR = `${ESC}[2J${ESC}[H`;
const HIDE_CUR = `${ESC}[?25l`;
const SHOW_CUR = `${ESC}[?25h`;
const INV = `${ESC}[7m`;
const RST = `${ESC}[0m`;
const DIM = `${ESC}[2m`;
const BOLD = `${ESC}[1m`;
function pickHost(channel, uid, hosts) {
return new Promise((resolve) => {
if (!hosts.length) {
channel.write(`\r\n No hosts available for ${uid}.\r\n (You have no directory access to any SSH host.)\r\n\r\n`);
setTimeout(() => resolve(null), 50);
return;
}
let filter = '';
let selected = 0;
const visible = () => hosts.filter((h) => {
if (!filter) return true;
const hay = `${h.name} ${h.slug} ${(h.metadata && h.metadata.ip) || ''}`.toLowerCase();
return hay.includes(filter.toLowerCase());
});
const render = () => {
const list = visible();
if (selected >= list.length) selected = Math.max(0, list.length - 1);
let out = CLEAR + HIDE_CUR;
out += `${BOLD} Theta42 Jump — hosts for ${uid}${RST}\r\n`;
out += `${DIM} ↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n\r\n`;
if (!list.length) {
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
} else {
list.forEach((h, i) => {
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
});
}
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
channel.write(out);
};
const done = (host) => {
channel.removeListener('data', onData);
channel.write(SHOW_CUR);
resolve(host);
};
const onData = (buf) => {
const s = buf.toString('utf8');
const list = visible();
if (s === '\x03' || s === 'q') return done(null); // Ctrl-C / q
if (s === '\x0c') return render(); // Ctrl-L
if (s === `${ESC}[A`) { selected = Math.max(0, selected - 1); return render(); }
if (s === `${ESC}[B`) { selected = Math.min(list.length - 1, selected + 1); return render(); }
if (s === '\r' || s === '\n') { if (list[selected]) return done(list[selected]); return; }
if (s === '\x7f' || s === '\b') { filter = filter.slice(0, -1); selected = 0; return render(); }
if (/^[0-9]$/.test(s)) { const i = Number(s) - 1; if (list[i]) return done(list[i]); return; }
if (s.length === 1 && s >= ' ') { filter += s; selected = 0; return render(); }
};
channel.on('data', onData);
render();
});
}
module.exports = { pickHost };