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;