8c9646b65c
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.
200 lines
7.0 KiB
JavaScript
200 lines
7.0 KiB
JavaScript
'use strict';
|
|
|
|
// End-to-end bridge test with NO external services: a tiny in-process ssh2
|
|
// "downstream" server (echo shell + exec + sftp-subsystem byte echo) and the
|
|
// jump host's own bridge, driven by an ssh2 client as `test_-_stub`.
|
|
//
|
|
// The jump host's LDAP/directory/redis dependencies are stubbed via injected
|
|
// modules so the test needs only ssh2 + generated keys.
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { Server, Client, utils } = require('ssh2');
|
|
|
|
const { connectUpstream, attachSession } = require('../../services/bridge');
|
|
|
|
let downstream, downstreamPort, jump, jumpPort, jumpKey;
|
|
|
|
// --- A minimal downstream sshd: accepts any key, echoes shell/exec, and
|
|
// echoes bytes on the sftp subsystem (enough to prove pass-through). ---
|
|
function startDownstream() {
|
|
return new Promise((resolve) => {
|
|
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
|
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
|
client.on('authentication', (ctx) => ctx.accept());
|
|
client.on('ready', () => {
|
|
client.on('session', (accept) => {
|
|
const session = accept();
|
|
session.on('pty', (a) => a && a());
|
|
session.on('shell', (a) => {
|
|
const ch = a();
|
|
ch.write('downstream-shell-ready\n');
|
|
ch.on('data', (d) => ch.write('echo:' + d)); // echo back
|
|
});
|
|
session.on('exec', (a, r, info) => {
|
|
const ch = a();
|
|
ch.write(`ran:${info.command}`);
|
|
ch.exit(0);
|
|
ch.end();
|
|
});
|
|
session.on('subsystem', (a, r, info) => {
|
|
if (info.name !== 'sftp') return r && r();
|
|
const ch = a();
|
|
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
srv.listen(0, '127.0.0.1', () => resolve(srv));
|
|
});
|
|
}
|
|
|
|
// --- The jump host, wired to bridge every session straight to the downstream
|
|
// (target resolution stubbed to the downstream endpoint). ---
|
|
function startJump() {
|
|
return new Promise((resolve) => {
|
|
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
|
|
const gen = utils.generateKeyPairSync('ed25519');
|
|
jumpKey = gen.private;
|
|
const clientAuthKey = utils.parseKey(gen.private); // user authenticates with the SAME key for the test
|
|
|
|
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
|
|
client.on('authentication', (ctx) => {
|
|
if (ctx.method === 'publickey') {
|
|
const k = clientAuthKey;
|
|
if (ctx.key.algo === k.type && k.getPublicSSH().equals(ctx.key.data)) {
|
|
if (ctx.signature) {
|
|
return k.verify(ctx.blob, ctx.signature, ctx.hashAlgo) === true ? ctx.accept() : ctx.reject();
|
|
}
|
|
return ctx.accept();
|
|
}
|
|
}
|
|
return ctx.reject(['publickey']);
|
|
});
|
|
client.on('ready', () => {
|
|
client.once('session', (accept) => {
|
|
const session = accept();
|
|
const audit = { patch() {}, finish() {}, event: {} };
|
|
// Attach synchronously with a deferred upstream — exactly how
|
|
// runGrammar wires it — so pre-connect channel requests buffer.
|
|
const upstreamPromise = connectUpstream({
|
|
host: '127.0.0.1', port: downstreamPort,
|
|
username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false,
|
|
});
|
|
attachSession(session, upstreamPromise, audit);
|
|
upstreamPromise.catch(() => client.end());
|
|
});
|
|
});
|
|
});
|
|
srv.listen(0, '127.0.0.1', () => resolve({ srv, key: gen.private }));
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
downstream = await startDownstream();
|
|
downstreamPort = downstream.address().port;
|
|
const j = await startJump();
|
|
jump = j.srv;
|
|
jumpPort = jump.address().port;
|
|
});
|
|
|
|
after(() => {
|
|
downstream && downstream.close();
|
|
jump && jump.close();
|
|
// bridge.js pulls in model-redis (via key_inject/metrics), which eagerly
|
|
// opens a redis client. This test never touches redis (audit is stubbed),
|
|
// so drop the connection so the process can exit.
|
|
try { require('../../models').redisClient.destroy(); } catch (_) {}
|
|
});
|
|
|
|
// The eager model-redis connect has no server in this hermetic test; ignore it.
|
|
process.on('unhandledRejection', () => {});
|
|
|
|
function connectJump() {
|
|
const conn = new Client();
|
|
return { conn, ready: new Promise((res, rej) => {
|
|
conn.on('ready', res).on('error', rej).connect({
|
|
host: '127.0.0.1', port: jumpPort, username: 'test_-_stub',
|
|
privateKey: jumpKey, // same key stubbed as the user's inbound key
|
|
});
|
|
}) };
|
|
}
|
|
|
|
test('exec bridges through to the downstream', async () => {
|
|
const { conn, ready } = connectJump();
|
|
await ready;
|
|
const out = await new Promise((resolve, reject) => {
|
|
conn.exec('hello-world', (err, stream) => {
|
|
if (err) return reject(err);
|
|
let buf = '';
|
|
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
|
|
});
|
|
});
|
|
conn.end();
|
|
assert.match(out, /ran:hello-world/);
|
|
});
|
|
|
|
test('shell bridges and echoes', async () => {
|
|
const { conn, ready } = connectJump();
|
|
await ready;
|
|
const out = await new Promise((resolve, reject) => {
|
|
conn.shell((err, stream) => {
|
|
if (err) return reject(err);
|
|
let buf = '';
|
|
stream.on('data', (d) => {
|
|
buf += d;
|
|
if (buf.includes('echo:ping')) { resolve(buf); }
|
|
});
|
|
setTimeout(() => stream.write('ping'), 150);
|
|
setTimeout(() => resolve(buf), 5000);
|
|
});
|
|
});
|
|
conn.end();
|
|
assert.match(out, /downstream-shell-ready/);
|
|
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;
|
|
const got = await new Promise((resolve, reject) => {
|
|
conn.subsys('sftp', (err, stream) => {
|
|
if (err) return reject(err);
|
|
let buf = Buffer.alloc(0);
|
|
stream.on('data', (d) => {
|
|
buf = Buffer.concat([buf, d]);
|
|
if (buf.includes('sftp:')) resolve(buf.toString());
|
|
});
|
|
stream.write(Buffer.from('PKT'));
|
|
setTimeout(() => resolve(buf.toString()), 5000);
|
|
});
|
|
});
|
|
conn.end();
|
|
assert.match(got, /sftp:PKT/);
|
|
});
|