266 lines
7.6 KiB
JavaScript
266 lines
7.6 KiB
JavaScript
|
|
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);
|
||
|
|
});
|