a3a6787776
request failed" / "shell request failed" runTuiSession awaited audit.create() (a Redis round-trip) and then accessibleHosts() (a directory API call) BEFORE calling runTui(), which is where the pty/shell/exec/subsystem listeners actually get attached to the session. The client sends its pty-req and shell requests immediately after opening the session -- if either await took long enough for those requests to arrive first, ssh2 auto-rejects any channel request with no listener (CHANNEL_FAILURE), which is exactly what OpenSSH reports as "PTY allocation request failed on channel 0" / "shell request failed on channel 0". The connection then just sat there, since nothing was left to drive it. runGrammar already has this exact fix (see its own comment); runTuiSession never got the equivalent treatment. Fixed the same way: register the session listeners synchronously, before any await, by having runTui take a Promise for the hosts list instead of the resolved list -- the shell handler awaits it internally once the client actually sends a shell request, which by construction happens only after the listener already exists. Verified: publickey auth against a real deployment succeeds (confirms the earlier ldaps:// fix holds), and the failure reproduces with the exact reported error strings for a bare `ssh user@host` (TUI/picker mode, no target) connection. Full suite: 50/50 passing, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
330 lines
13 KiB
JavaScript
330 lines
13 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) {
|
|
// Register session listeners IMMEDIATELY — before any async work.
|
|
// The client sends exec/shell requests right after opening the session;
|
|
// if we await audit.create() first, those requests arrive before the
|
|
// listeners are registered and ssh2 rejects them with CHANNEL_FAILURE.
|
|
let resolveUp, rejectUp;
|
|
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
|
|
const dummyAudit = { patch() {}, finish() {}, event: {} };
|
|
attachSession(session, upstreamPromise, dummyAudit);
|
|
|
|
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
|
|
// Wire the real audit record into the already-attached session.
|
|
dummyAudit.patch = (...a) => record.patch(...a);
|
|
dummyAudit.finish = (...a) => record.finish(...a);
|
|
Object.defineProperty(dummyAudit, 'event', { get: () => record.event });
|
|
|
|
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) {
|
|
// Register session listeners IMMEDIATELY, before any await — same fix,
|
|
// same reason, as runGrammar above. The client sends pty-req and shell
|
|
// requests right after opening the session; awaiting audit.create() and
|
|
// accessibleHosts() first (both real round-trips: Redis, then the
|
|
// directory API) left a window where those requests could arrive before
|
|
// runTui had attached any listener for them, and ssh2 auto-rejects an
|
|
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
|
|
// client as "PTY allocation request failed" / "shell request failed",
|
|
// with the connection then just sitting there (nothing left to drive it).
|
|
let resolveHosts, rejectHosts;
|
|
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
|
|
// A silent catch so a rejection isn't "unhandled" if the client never
|
|
// sends a shell request at all (exec-only) — runTui's own .catch() below
|
|
// still runs independently when it does.
|
|
hostsPromise.catch(() => {});
|
|
const tuiPromise = runTui(session, state.uid, hostsPromise);
|
|
|
|
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);
|
|
resolveHosts(hosts);
|
|
} catch (_) {
|
|
rejectHosts(new Error('directory-unreachable'));
|
|
return finishFail('directory-unreachable');
|
|
}
|
|
|
|
const tui = await tuiPromise;
|
|
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.
|
|
// Takes a Promise for the accessible-hosts list (not the resolved list)
|
|
// so the caller can register these listeners before that lookup completes
|
|
// — see the comment in runTuiSession for why that ordering matters.
|
|
function runTui(session, uid, hostsPromise) {
|
|
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();
|
|
hostsPromise.then((hosts) => {
|
|
pickHost(channel, uid, hosts).then((host) => {
|
|
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
|
|
finish({ host, channel, ptyInfo });
|
|
});
|
|
}).catch(() => {
|
|
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
|
|
finish({ host: null });
|
|
});
|
|
});
|
|
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 } };
|