Files
jump-host/nodejs/services/tui_picker.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

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