Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6af160627 | |||
| 8c9646b65c |
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.8.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
|
||||||
|
|
||||||
## [1.8.1] - 2026-07-28
|
## [1.8.1] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-jump-host",
|
"name": "t42-jump-host",
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,5 +27,6 @@ app.apiToken = (function(app){
|
|||||||
// Shared render helpers.
|
// Shared render helpers.
|
||||||
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
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.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>'
|
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</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;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -139,12 +139,16 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey,
|
uid: state.uid, justInjected, onHostKey,
|
||||||
});
|
});
|
||||||
} catch (_) { throw fail('upstream-unreachable'); }
|
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
||||||
|
|
||||||
return { upstream, host, endpoint };
|
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) {
|
async function runGrammar(session, client, state) {
|
||||||
// Register session listeners IMMEDIATELY — before any async work.
|
// Register session listeners IMMEDIATELY — before any async work.
|
||||||
@@ -174,7 +178,7 @@ async function runGrammar(session, client, state) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err.reason || 'error';
|
const reason = err.reason || 'error';
|
||||||
rejectUp(new Error(reasonMessage(reason)));
|
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 });
|
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 record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||||
|
|
||||||
const finishFail = async (reason) => {
|
const finishFail = async (reason, detail) => {
|
||||||
await record.finish({ success: false, failReason: reason });
|
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||||
await metrics.bump({ uid: state.uid, success: false });
|
await metrics.bump({ uid: state.uid, success: false });
|
||||||
try { client.end(); } catch (_) {}
|
try { client.end(); } catch (_) {}
|
||||||
};
|
};
|
||||||
@@ -223,7 +227,7 @@ async function runTuiSession(session, client, state) {
|
|||||||
|
|
||||||
let justInjected = false;
|
let justInjected = false;
|
||||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
@@ -232,9 +236,9 @@ async function runTuiSession(session, client, state) {
|
|||||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
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 (_) {}
|
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 });
|
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/);
|
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 () => {
|
test('sftp subsystem bytes pass through', async () => {
|
||||||
const { conn, ready } = connectJump();
|
const { conn, ready } = connectJump();
|
||||||
await ready;
|
await ready;
|
||||||
|
|||||||
Reference in New Issue
Block a user