Files
jump-host/nodejs/services/ssh_server.js
T
wmantly 36e9d5b0b3 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>
2026-07-23 15:48:49 -04:00

292 lines
11 KiB
JavaScript

'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 } };