36e9d5b0b3
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>
78 lines
2.8 KiB
JavaScript
78 lines
2.8 KiB
JavaScript
'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 };
|