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:
@@ -0,0 +1,176 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end bridge test with NO external services: a tiny in-process ssh2
|
||||
// "downstream" server (echo shell + exec + sftp-subsystem byte echo) and the
|
||||
// jump host's own bridge, driven by an ssh2 client as `test_-_stub`.
|
||||
//
|
||||
// The jump host's LDAP/directory/redis dependencies are stubbed via injected
|
||||
// modules so the test needs only ssh2 + generated keys.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { Server, Client, utils } = require('ssh2');
|
||||
|
||||
const { connectUpstream, attachSession } = require('../../services/bridge');
|
||||
|
||||
let downstream, downstreamPort, jump, jumpPort, jumpKey;
|
||||
|
||||
// --- A minimal downstream sshd: accepts any key, echoes shell/exec, and
|
||||
// echoes bytes on the sftp subsystem (enough to prove pass-through). ---
|
||||
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)); // echo back
|
||||
});
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// --- The jump host, wired to bridge every session straight to the downstream
|
||||
// (target resolution stubbed to the downstream endpoint). ---
|
||||
function startJump() {
|
||||
return new Promise((resolve) => {
|
||||
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
||||
const gen = utils.generateKeyPairSync('ed25519');
|
||||
jumpKey = gen.private;
|
||||
const clientAuthKey = utils.parseKey(gen.private); // user authenticates with the SAME key for the test
|
||||
|
||||
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
client.on('authentication', (ctx) => {
|
||||
if (ctx.method === 'publickey') {
|
||||
const k = clientAuthKey;
|
||||
if (ctx.key.algo === k.type && k.getPublicSSH().equals(ctx.key.data)) {
|
||||
if (ctx.signature) {
|
||||
return k.verify(ctx.blob, ctx.signature, ctx.hashAlgo) === true ? ctx.accept() : ctx.reject();
|
||||
}
|
||||
return ctx.accept();
|
||||
}
|
||||
}
|
||||
return ctx.reject(['publickey']);
|
||||
});
|
||||
client.on('ready', () => {
|
||||
client.once('session', (accept) => {
|
||||
const session = accept();
|
||||
const audit = { patch() {}, finish() {}, event: {} };
|
||||
// Attach synchronously with a deferred upstream — exactly how
|
||||
// runGrammar wires it — so pre-connect channel requests buffer.
|
||||
const upstreamPromise = connectUpstream({
|
||||
host: '127.0.0.1', port: downstreamPort,
|
||||
username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false,
|
||||
});
|
||||
attachSession(session, upstreamPromise, audit);
|
||||
upstreamPromise.catch(() => client.end());
|
||||
});
|
||||
});
|
||||
});
|
||||
srv.listen(0, '127.0.0.1', () => resolve({ srv, key: gen.private }));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
downstream = await startDownstream();
|
||||
downstreamPort = downstream.address().port;
|
||||
const j = await startJump();
|
||||
jump = j.srv;
|
||||
jumpPort = jump.address().port;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
downstream && downstream.close();
|
||||
jump && jump.close();
|
||||
// bridge.js pulls in model-redis (via key_inject/metrics), which eagerly
|
||||
// opens a redis client. This test never touches redis (audit is stubbed),
|
||||
// so drop the connection so the process can exit.
|
||||
try { require('../../models').redisClient.destroy(); } catch (_) {}
|
||||
});
|
||||
|
||||
// The eager model-redis connect has no server in this hermetic test; ignore it.
|
||||
process.on('unhandledRejection', () => {});
|
||||
|
||||
function connectJump() {
|
||||
const conn = new Client();
|
||||
return { conn, ready: new Promise((res, rej) => {
|
||||
conn.on('ready', res).on('error', rej).connect({
|
||||
host: '127.0.0.1', port: jumpPort, username: 'test_-_stub',
|
||||
privateKey: jumpKey, // same key stubbed as the user's inbound key
|
||||
});
|
||||
}) };
|
||||
}
|
||||
|
||||
test('exec bridges through to the downstream', async () => {
|
||||
const { conn, ready } = connectJump();
|
||||
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('shell bridges and echoes', async () => {
|
||||
const { conn, ready } = connectJump();
|
||||
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'), 100);
|
||||
setTimeout(() => resolve(buf), 1500);
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(out, /downstream-shell-ready/);
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('sftp subsystem bytes pass through', async () => {
|
||||
const { conn, ready } = connectJump();
|
||||
await ready;
|
||||
const got = await new Promise((resolve, reject) => {
|
||||
conn.subsys('sftp', (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let buf = Buffer.alloc(0);
|
||||
stream.on('data', (d) => {
|
||||
buf = Buffer.concat([buf, d]);
|
||||
if (buf.includes('sftp:')) resolve(buf.toString());
|
||||
});
|
||||
stream.write(Buffer.from('PKT'));
|
||||
setTimeout(() => resolve(buf.toString()), 1500);
|
||||
});
|
||||
});
|
||||
conn.end();
|
||||
assert.match(got, /sftp:PKT/);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { accessibleHosts, clearCache } = require('../../utils/access');
|
||||
|
||||
function stubLdap(groups) {
|
||||
return { getGroups: async () => groups };
|
||||
}
|
||||
|
||||
function stubFetch(byGroup) {
|
||||
return async (url) => {
|
||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
||||
};
|
||||
}
|
||||
|
||||
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||
const fetchImpl = stubFetch({
|
||||
host_web01_access: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
||||
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
||||
],
|
||||
host_db_access: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
|
||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||
],
|
||||
});
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
test('a failing group query does not sink the rest', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
||||
});
|
||||
|
||||
test('caches per uid', async () => {
|
||||
clearCache();
|
||||
let calls = 0;
|
||||
const user = { uid: 'cara', dn: 'd' };
|
||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||
const ldap = { getGroups: async () => ['g1'] };
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
assert.strictEqual(calls, 1);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { ensureKeys, pubLine, generatePair } = require('../../utils/host_keys');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
test('generates a parseable ed25519 public line', () => {
|
||||
const pem = generatePair('ed25519');
|
||||
const line = pubLine(pem, 'jump-host@test');
|
||||
assert.match(line, /^ssh-ed25519 [A-Za-z0-9+/=]+ jump-host@test$/);
|
||||
});
|
||||
|
||||
test('ensureKeys writes and reloads a stable keypair', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jh-keys-'));
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
conf.ssh = { ...conf.ssh, hostKeyPath: dir, keyComment: 'jump-host@test' };
|
||||
|
||||
const first = ensureKeys(dir);
|
||||
assert.strictEqual(first.hostKeys.length, 2);
|
||||
assert.match(first.publicLine, /jump-host@test$/);
|
||||
|
||||
const second = ensureKeys(dir);
|
||||
assert.strictEqual(second.publicLine, first.publicLine); // stable, not regenerated
|
||||
assert.ok(fs.existsSync(path.join(dir, 'id_ed25519')));
|
||||
assert.ok(fs.existsSync(path.join(dir, 'id_rsa')));
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { matchTarget, hostEndpoint } = require('../../utils/target_match');
|
||||
|
||||
const hosts = [
|
||||
{ id: '1', slug: 'host_web01', name: 'Web 01', metadata: { ip: '10.0.0.10', sshPort: 2200 } },
|
||||
{ id: '2', slug: 'host_db', name: 'Database', metadata: { address: 'ssh://db.internal:22' } },
|
||||
];
|
||||
|
||||
test('exact slug', () => {
|
||||
assert.strictEqual(matchTarget('host_web01', hosts).host.id, '1');
|
||||
});
|
||||
|
||||
test('host_-prefixed shorthand', () => {
|
||||
assert.strictEqual(matchTarget('web01', hosts).host.id, '1');
|
||||
});
|
||||
|
||||
test('by display name (case-insensitive)', () => {
|
||||
assert.strictEqual(matchTarget('database', hosts).host.id, '2');
|
||||
});
|
||||
|
||||
test('by ip', () => {
|
||||
assert.strictEqual(matchTarget('10.0.0.10', hosts).host.id, '1');
|
||||
});
|
||||
|
||||
test('by address hostname', () => {
|
||||
assert.strictEqual(matchTarget('db.internal', hosts).host.id, '2');
|
||||
});
|
||||
|
||||
test('raw IP denied by default', () => {
|
||||
assert.throws(() => matchTarget('8.8.8.8', hosts), (e) => e.code === 'no-such-target');
|
||||
});
|
||||
|
||||
test('raw IP allowed when configured', () => {
|
||||
const m = matchTarget('8.8.8.8', hosts, { allowRawIPs: true });
|
||||
assert.strictEqual(m.host, null);
|
||||
assert.strictEqual(m.raw, '8.8.8.8');
|
||||
});
|
||||
|
||||
test('unknown slug denied', () => {
|
||||
assert.throws(() => matchTarget('nope', hosts), (e) => e.code === 'no-such-target');
|
||||
});
|
||||
|
||||
test('hostEndpoint uses sshPort then default', () => {
|
||||
assert.deepStrictEqual(hostEndpoint(hosts[0], 22), { address: '10.0.0.10', port: 2200 });
|
||||
assert.deepStrictEqual(hostEndpoint(hosts[1], 22), { address: 'db.internal', port: 22 });
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { parseUsername, isIPv4 } = require('../../utils/username_grammar');
|
||||
|
||||
test('plain uid → picker mode', () => {
|
||||
assert.deepStrictEqual(parseUsername('alice'), { uid: 'alice', target: null });
|
||||
});
|
||||
|
||||
test('uid_-_slug → grammar mode', () => {
|
||||
assert.deepStrictEqual(parseUsername('alice_-_web01'), { uid: 'alice', target: 'web01' });
|
||||
});
|
||||
|
||||
test('uid_-_host_slug (prefixed target)', () => {
|
||||
assert.deepStrictEqual(parseUsername('bob_-_host_pve1'), { uid: 'bob', target: 'host_pve1' });
|
||||
});
|
||||
|
||||
test('uid_-_ipv4', () => {
|
||||
assert.deepStrictEqual(parseUsername('bob_-_10.0.0.5'), { uid: 'bob', target: '10.0.0.5' });
|
||||
});
|
||||
|
||||
test('splits on first _-_ only', () => {
|
||||
// target may legitimately contain a dash; the separator is the first _-_
|
||||
assert.deepStrictEqual(parseUsername('carol_-_web-01'), { uid: 'carol', target: 'web-01' });
|
||||
});
|
||||
|
||||
test('rejects invalid uid', () => {
|
||||
assert.throws(() => parseUsername('Bad Uid'));
|
||||
assert.throws(() => parseUsername('1abc'));
|
||||
});
|
||||
|
||||
test('rejects empty target', () => {
|
||||
assert.throws(() => parseUsername('alice_-_'));
|
||||
});
|
||||
|
||||
test('rejects empty username', () => {
|
||||
assert.throws(() => parseUsername(''));
|
||||
});
|
||||
|
||||
test('isIPv4', () => {
|
||||
assert.ok(isIPv4('192.168.1.1'));
|
||||
assert.ok(!isIPv4('999.1.1.1'));
|
||||
assert.ok(!isIPv4('web01'));
|
||||
});
|
||||
Reference in New Issue
Block a user