Files
NetMesh/electron/bridges/sshBridge.connectionTimeout.test.cjs

266 lines
7.6 KiB
JavaScript
Raw Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
function loadBridgeWithMockedSsh2(t) {
const bridgePath = require.resolve("./sshBridge.cjs");
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
const originalLoad = Module._load;
class MockSSHClient extends EventEmitter {
constructor() {
super();
MockSSHClient.instances.push(this);
this._sock = {
timeouts: [],
setTimeout: (ms) => {
this._sock.timeouts.push(ms);
},
};
this.connectOpts = null;
this.destroyed = false;
this.ended = false;
}
connect(opts) {
this.connectOpts = opts;
setImmediate(() => {
this.emit("connect");
if (MockSSHClient.emitSocketTimeout) this.emit("timeout");
});
}
end() {
this.ended = true;
}
destroy() {
this.destroyed = true;
}
}
MockSSHClient.instances = [];
MockSSHClient.emitSocketTimeout = true;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "ssh2") {
return {
Client: MockSSHClient,
utils: { parseKey: () => new Error("no key") },
};
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
const bridge = require("./sshBridge.cjs");
t.after(() => {
delete require.cache[bridgePath];
delete require.cache[authHelperPath];
Module._load = originalLoad;
});
return { bridge, MockSSHClient };
}
function makeSender() {
return {
id: 1,
isDestroyed: () => false,
sent: [],
send(channel, payload) {
this.sent.push({ channel, payload });
},
};
}
function registerStartHandler(bridge, sessions) {
bridge.init({ sessions, electronModule: {} });
const ipcMain = {
handlers: new Map(),
handle(channel, handler) {
this.handlers.set(channel, handler);
},
on() {},
};
bridge.registerHandlers(ipcMain);
return ipcMain.handlers.get("netcatty:start");
}
function collectAuthMethods(authHandler, maxSteps = 16) {
const labels = [];
let methodsLeft = null;
for (let i = 0; i < maxSteps; i += 1) {
let offered = null;
authHandler(methodsLeft, false, (method) => {
offered = method;
});
if (!offered) break;
labels.push(typeof offered === "string" ? offered : offered.type);
methodsLeft = ["publickey", "password", "keyboard-interactive", "agent"];
}
return labels;
}
async function withFakeDefaultKey(run) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-auto-auth-"));
const sshDir = path.join(home, ".ssh");
fs.mkdirSync(sshDir);
fs.writeFileSync(
path.join(sshDir, "id_rsa"),
"-----BEGIN RSA PRIVATE KEY-----\nMIIBfake-auto-auth\n-----END RSA PRIVATE KEY-----\n",
);
const originalHomedir = os.homedir;
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
os.homedir = () => home;
delete process.env.SSH_AUTH_SOCK;
try {
return await run();
} finally {
os.homedir = originalHomedir;
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
fs.rmSync(home, { recursive: true, force: true });
}
}
test("SSH start automatic mode tries local keys before its saved password", async (t) => {
await withFakeDefaultKey(async () => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const start = registerStartHandler(bridge, new Map());
await assert.rejects(
() => start(
{ sender: makeSender() },
{
sessionId: "ssh-auto-auth",
hostname: "192.0.2.9",
username: "alice",
port: 22,
authMethod: "auto",
password: "saved-secret",
knownHosts: [],
},
),
/Connection timeout/,
);
const labels = collectAuthMethods(MockSSHClient.instances[0].connectOpts.authHandler);
assert.ok(labels.includes("publickey"), `expected publickey; got ${labels.join(",")}`);
assert.ok(labels.indexOf("publickey") < labels.indexOf("password"), labels.join(","));
assert.ok(labels.indexOf("publickey") < labels.indexOf("keyboard-interactive"), labels.join(","));
// Default hosts keep password before keyboard-interactive.
assert.ok(labels.indexOf("password") < labels.indexOf("keyboard-interactive"), labels.join(","));
});
});
test("SSH start keeps TCP dial and auth readiness as separate timeout phases", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
await assert.rejects(
() => start(
{ sender },
{
sessionId: "ssh-timeout",
hostname: "192.0.2.10",
username: "alice",
port: 22,
password: "secret",
knownHosts: [],
},
),
/Connection timeout to 192\.0\.2\.10/,
);
assert.equal(MockSSHClient.instances.length, 1);
const client = MockSSHClient.instances[0];
assert.equal(client.connectOpts.timeout, 20_000);
assert.equal(client.connectOpts.readyTimeout, 0);
assert.deepEqual(client._sock.timeouts, [0]);
assert.ok(sender.sent.some((message) => (
message.channel === "netcatty:chain:progress"
&& message.payload.sessionId === "ssh-timeout"
&& message.payload.status === "tcp-connected"
)));
assert.ok(sender.sent.some((message) => (
message.channel === "netcatty:exit"
&& message.payload.sessionId === "ssh-timeout"
&& message.payload.reason === "timeout"
)));
});
test("SSH start applies configured TCP and authentication timeouts", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
const start = registerStartHandler(bridge, new Map());
await assert.rejects(
() => start(
{ sender: makeSender() },
{
sessionId: "ssh-custom-timeout",
hostname: "192.0.2.20",
username: "alice",
port: 22,
password: "secret",
knownHosts: [],
sshTcpConnectTimeoutMs: 45_000,
sshAuthReadyTimeoutMs: 300_000,
},
),
/Connection timeout to 192\.0\.2\.20/,
);
assert.equal(MockSSHClient.instances.length, 1);
assert.equal(MockSSHClient.instances[0].connectOpts.timeout, 45_000);
assert.equal(MockSSHClient.instances[0].connectOpts.readyTimeout, 0);
});
test("SSH authentication timeout starts only after TCP connects", async (t) => {
const { bridge, MockSSHClient } = loadBridgeWithMockedSsh2(t);
MockSSHClient.emitSocketTimeout = false;
const start = registerStartHandler(bridge, new Map());
const startedAt = Date.now();
let guardTimer;
const guard = new Promise((_, reject) => {
guardTimer = setTimeout(
() => reject(new Error("Authentication timeout test exceeded 3 seconds")),
3_000,
);
});
try {
await assert.rejects(
Promise.race([
start(
{ sender: makeSender() },
{
sessionId: "ssh-auth-timeout",
hostname: "192.0.2.30",
username: "alice",
port: 22,
password: "secret",
knownHosts: [],
sshTcpConnectTimeoutMs: 45_000,
sshAuthReadyTimeoutMs: 1_000,
},
),
guard,
]),
/Connection timeout to 192\.0\.2\.30/,
);
} finally {
clearTimeout(guardTimer);
}
assert.ok(Date.now() - startedAt >= 900);
assert.equal(MockSSHClient.instances[0].connectOpts.timeout, 45_000);
});