diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js index 5bfcecf..5bd9595 100644 --- a/nodejs/public/js/app.js +++ b/nodejs/public/js/app.js @@ -27,5 +27,6 @@ app.apiToken = (function(app){ // Shared render helpers. app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; }; app.jump.esc = function(s){ return $('
').text(s == null ? '' : String(s)).html(); }; -app.jump.result = function(e){ return e.success ? 'ok' - : '' + app.jump.esc(e.failReason || 'fail') + ''; }; +app.jump.result = function(e){ if (e.success) return 'ok'; + var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : ''; + return '' + app.jump.esc(e.failReason || 'fail') + ''; }; diff --git a/nodejs/services/ssh_server.js b/nodejs/services/ssh_server.js index 0d89eeb..20d6afb 100644 --- a/nodejs/services/ssh_server.js +++ b/nodejs/services/ssh_server.js @@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { let justInjected = false; try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (_) { throw fail('key-inject-failed'); } + catch (err) { throw fail('key-inject-failed', err.message); } let upstream; try { @@ -139,12 +139,16 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { username: state.uid, privateKey: JUMP_KEYS.clientKey, uid: state.uid, justInjected, onHostKey, }); - } catch (_) { throw fail('upstream-unreachable'); } + } catch (err) { throw fail('upstream-unreachable', err.message); } return { upstream, host, endpoint }; } -function fail(reason) { const e = new Error(reason); e.reason = reason; return e; } +// detail carries the real underlying error message (e.g. ECONNREFUSED, +// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to +// just the generic reason code -- without it, a network-layer failure and an +// SSH auth failure both looked identical in the audit log. +function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; } async function runGrammar(session, client, state) { // Register session listeners IMMEDIATELY — before any async work. @@ -174,7 +178,7 @@ async function runGrammar(session, client, state) { } catch (err) { const reason = err.reason || 'error'; rejectUp(new Error(reasonMessage(reason))); - await record.finish({ success: false, failReason: reason }); + await record.finish({ success: false, failReason: reason, failDetail: err.detail }); await metrics.bump({ uid: state.uid, success: false }); } } @@ -199,8 +203,8 @@ async function runTuiSession(session, client, state) { 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 }); + const finishFail = async (reason, detail) => { + await record.finish({ success: false, failReason: reason, failDetail: detail }); await metrics.bump({ uid: state.uid, success: false }); try { client.end(); } catch (_) {} }; @@ -223,7 +227,7 @@ async function runTuiSession(session, client, state) { let justInjected = false; try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); } - catch (_) { return finishFail('key-inject-failed'); } + catch (err) { return finishFail('key-inject-failed', err.message); } let upstream; try { @@ -232,9 +236,9 @@ async function runTuiSession(session, client, state) { username: state.uid, privateKey: JUMP_KEYS.clientKey, uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }), }); - } catch (_) { + } catch (err) { try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {} - return finishFail('upstream-unreachable'); + return finishFail('upstream-unreachable', err.message); } registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug }); diff --git a/nodejs/test/integration/ssh_bridge.test.js b/nodejs/test/integration/ssh_bridge.test.js index ca1cf5c..df7697a 100644 --- a/nodejs/test/integration/ssh_bridge.test.js +++ b/nodejs/test/integration/ssh_bridge.test.js @@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => { assert.match(out, /echo:ping/); }); +test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => { + // Regression coverage for ssh_server.js's resolveAndConnect: it used to + // discard this error entirely (catch (_) { throw fail('upstream-unreachable') }), + // so the audit log recorded the same generic reason for a refused port, a + // timeout, or a bad key alike. Now the real message is threaded through as + // failDetail, so this must stay meaningful. + // Bind a server just to reserve a free port, then close it immediately so + // nothing is listening there — guarantees ECONNREFUSED rather than relying + // on a hardcoded port number that might be in use. + const closedPort = await new Promise((resolve) => { + const probe = require('net').createServer(); + probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); }); + }); + await assert.rejects( + connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }), + (err) => { + assert.ok(err.message && err.message.length > 0); + assert.notStrictEqual(err.message, 'upstream-unreachable'); + return true; + }, + ); +}); + test('sftp subsystem bytes pass through', async () => { const { conn, ready } = connectJump(); await ready;