Thread real upstream-connect errors into audit records as failDetail (#19)
resolveAndConnect discarded the actual error from connectUpstream (ECONNREFUSED, ETIMEDOUT, an ssh2 auth failure, ...) and replaced it with the generic reason string 'upstream-unreachable', so the audit log gave no way to tell a network-layer failure from an auth failure -- which is why "Could not reach 192.168.1.206" for the emby host couldn't be root-caused without live host-shell access. Now the real error message is captured and surfaced as failDetail, shown as a tooltip on the audit table's fail badge.
This commit is contained in:
@@ -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 $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
|
||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||
var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
|
||||
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user