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
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Which directory hosts may a user reach, and how do we dial them?
//
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// /api/discovery/me only answers for the API token's own user, and /graph
// 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 userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000;
const cache = new Map(); // uid -> {at, hosts}
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
const sso = conf.sso || {};
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${sso.apiToken}` },
});
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
const data = await res.json();
return (data && data.results) || [];
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
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 };
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// Jump host SSH identity: one keypair used both as the server host key and
// as the client key for upstream connections (the public half is what gets
// injected into users' sshPublicKey — see utils/key_inject.js).
//
// Generated on first boot into conf.ssh.hostKeyPath:
// ed25519 (id_ed25519 / id_ed25519.pub) — primary
// rsa-3072 (id_rsa / id_rsa.pub) — compatibility host key
//
// Node's crypto generates the keys; ssh2's parseKey consumes the PEMs and
// renders the OpenSSH-format public lines.
const fs = require('fs');
const path = require('path');
const { utils: { parseKey, generateKeyPairSync } } = require('ssh2');
const conf = require('@simpleworkjs/conf');
// ssh2's parseKey wants OpenSSH-format private keys (Node's crypto PKCS8 export
// isn't accepted for ed25519), so use ssh2's own generator.
function generatePair(type) {
const { private: priv } = generateKeyPairSync(type === 'ed25519' ? 'ed25519' : 'rsa',
type === 'ed25519' ? undefined : { bits: 3072 });
return priv;
}
function pubLine(privPem, comment) {
const parsed = parseKey(privPem);
if (parsed instanceof Error) throw parsed;
const key = Array.isArray(parsed) ? parsed[0] : parsed;
return `${key.type} ${key.getPublicSSH().toString('base64')} ${comment}`;
}
function ensureKeys(dir) {
dir = dir || (conf.ssh && conf.ssh.hostKeyPath);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const out = {};
for (const [type, name] of [['ed25519', 'id_ed25519'], ['rsa', 'id_rsa']]) {
const priv = path.join(dir, name);
if (!fs.existsSync(priv)) {
const pem = generatePair(type);
fs.writeFileSync(priv, pem, { mode: 0o600 });
fs.writeFileSync(`${priv}.pub`, pubLine(pem, conf.ssh.keyComment) + '\n', { mode: 0o644 });
}
out[type] = fs.readFileSync(priv, 'utf8');
}
return {
hostKeys: [out.ed25519, out.rsa],
clientKey: out.ed25519,
publicLine: pubLine(out.ed25519, conf.ssh.keyComment),
};
}
module.exports = { ensureKeys, pubLine, generatePair };
+46
View File
@@ -0,0 +1,46 @@
'use strict';
// Upstream auth: the jump host connects to downstream hosts as the real user
// with the jump host's OWN private key. For downstream sshd to accept it, the
// jump host's public key must be one of the user's sshPublicKey values in
// LDAP (downstream hosts serve keys from LDAP via ldap-client's
// AuthorizedKeysCommand / SSSD).
//
// So: before the first upstream connect for a user, append the jump host's
// public line (comment-marked, e.g. "... jump-host@local") to their
// sshPublicKey attribute. Idempotent: exact-value duplicates are a no-op
// (TypeOrValueExists handled in models/user_ldap.addSshKey). The redis flag
// jump_host_injected_<uid> skips the LDAP round-trip on later connects; a
// failed upstream auth clears it so a manually-removed key gets re-injected
// once (see services/bridge.js).
//
// The bind DN therefore needs WRITE access to sshPublicKey on ou=people —
// documented in the README (OpenLDAP ACL) and granted by theta-env's
// bootstrap for the bundled deployment.
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const { getRedis } = require('../models');
function flagKey(uid) {
return `${conf.redis.prefix}injected_${uid}`;
}
async function ensureKeyInjected(user, publicLine, { ldap = userLdap } = {}) {
const redis = await getRedis();
if (await redis.get(flagKey(user.uid))) return false;
const already = (user.sshPublicKeys || []).includes(publicLine);
if (!already) {
await ldap.addSshKey(user.dn, publicLine);
}
await redis.set(flagKey(user.uid), '1');
return !already; // true if we actually wrote (caller may pause for SSSD cache)
}
async function clearInjectedFlag(uid) {
const redis = await getRedis();
await redis.del(flagKey(uid));
}
module.exports = { ensureKeyInjected, clearInjectedFlag };
+69
View File
@@ -0,0 +1,69 @@
'use strict';
// Match a requested target string against the list of directory host
// resources the user may access (from utils/access.js). Matching order:
//
// 1. exact slug (host_web01)
// 2. host_-prefixed slug (web01 -> host_web01)
// 3. exact name (the directory display name, case-insensitive)
// 4. metadata.ip exact
// 5. metadata.address hostname exact (with or without scheme)
//
// A raw IPv4 target that matches no accessible host is allowed through only
// when allowRawIPs is set (the caller audits it as such); anything else that
// doesn't match is a no-access/no-such-target denial — the caller cannot
// tell those apart (by design: don't leak the inventory to unauthorized
// users).
//
// Returns { host, raw } — `host` is the matched resource (null for a
// permitted raw IP), `raw` is the literal address to dial when host is null.
// Throws { code: 'no-such-target' } when nothing matches.
const { isIPv4 } = require('./username_grammar');
function addrHost(address) {
if (!address) return null;
try {
return new URL(address.includes('://') ? address : `ssh://${address}`).hostname;
} catch (_) {
return address;
}
}
function matchTarget(target, hosts, { allowRawIPs = false } = {}) {
const t = String(target).toLowerCase();
const bySlug = hosts.find((h) => h.slug && h.slug.toLowerCase() === t);
if (bySlug) return { host: bySlug, raw: null };
const byPrefixed = hosts.find((h) => h.slug && h.slug.toLowerCase() === `host_${t}`);
if (byPrefixed) return { host: byPrefixed, raw: null };
const byName = hosts.find((h) => h.name && h.name.toLowerCase() === t);
if (byName) return { host: byName, raw: null };
const byIp = hosts.find((h) => h.metadata && h.metadata.ip === target);
if (byIp) return { host: byIp, raw: null };
const byAddr = hosts.find((h) => {
const a = addrHost(h.metadata && h.metadata.address);
return a && a.toLowerCase() === t;
});
if (byAddr) return { host: byAddr, raw: null };
if (isIPv4(target) && allowRawIPs) return { host: null, raw: target };
const err = new Error(`No accessible host matches '${target}'`);
err.code = 'no-such-target';
throw err;
}
// Resolve the address/port to dial for a matched host resource.
function hostEndpoint(host, defaultPort = 22) {
const md = host.metadata || {};
const address = md.ip || addrHost(md.address) || null;
const port = Number(md.sshPort) || defaultPort;
return { address, port };
}
module.exports = { matchTarget, hostEndpoint };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
// Parse the jump host's SSH username grammar:
//
// {uid} -> interactive TUI picker
// {uid}_-_{target} -> bridge straight to <target>
//
// where <target> is a directory host slug (with or without the host_ prefix),
// a bare hostname, or an IPv4 address. The separator `_-_` was chosen because
// it is legal in an SSH username everywhere (WinSCP included) and cannot
// appear in a POSIX uid. We split on the FIRST `_-_`: uids cannot contain it
// (POSIX uids don't allow the sequence in practice and the SSO's invite flow
// never generates one), while a target could in theory contain a later `-`
// sequence.
//
// Returns { uid, target } — target is null in picker mode.
// Throws on a syntactically invalid uid or target.
const SEP = '_-_';
// POSIX-ish uid: same shape the SSO enforces.
const UID_RE = /^[a-z_][a-z0-9._-]{0,31}$/;
// Directory slug chars (slugify output) or a hostname label string.
const TARGET_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function isIPv4(s) {
const m = IPV4_RE.exec(s);
if (!m) return false;
return m.slice(1).every((o) => Number(o) <= 255);
}
function parseUsername(username) {
if (typeof username !== 'string' || !username.length) {
throw new Error('Empty username');
}
const idx = username.indexOf(SEP);
if (idx === -1) {
if (!UID_RE.test(username)) throw new Error(`Invalid username: ${username}`);
return { uid: username, target: null };
}
const uid = username.slice(0, idx);
const target = username.slice(idx + SEP.length);
if (!UID_RE.test(uid)) throw new Error(`Invalid uid in username: ${uid}`);
if (!target.length || (!TARGET_RE.test(target) && !isIPv4(target))) {
throw new Error(`Invalid target in username: ${target}`);
}
return { uid, target };
}
module.exports = { parseUsername, isIPv4, SEP };