mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd15958256 | ||
|
|
0b8b8a5623 |
No files matched your search
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { Server } = require("ssh2");
|
||||
const bridge = require("./sshBridge.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const listen = (server, ...args) => new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(...args, resolve);
|
||||
});
|
||||
const close = (server) => new Promise((resolve) => server.close(resolve));
|
||||
|
||||
test("normal SSH startup preserves fallback after an unavailable system agent", {
|
||||
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
|
||||
timeout: 20_000,
|
||||
}, async (t) => {
|
||||
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
|
||||
const originalHomedir = os.homedir;
|
||||
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
os.homedir = () => fixture; // Never discover the developer's default keys.
|
||||
t.after(() => {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
|
||||
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
|
||||
fs.rmSync(fixture, { recursive: true, force: true });
|
||||
});
|
||||
const hostKey = crypto.generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
}).privateKey;
|
||||
|
||||
let sequence = 0;
|
||||
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
|
||||
const senderId = 3640 + sequence++;
|
||||
const socketPath = path.join(fixture, "s");
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
const agentClients = new Set();
|
||||
let agentChecks = 0;
|
||||
const agent = net.createServer((socket) => {
|
||||
agentClients.add(socket);
|
||||
agentChecks += 1;
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => agentClients.delete(socket));
|
||||
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
|
||||
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
|
||||
});
|
||||
await listen(agent, socketPath);
|
||||
if (stale) {
|
||||
// Preserve a real socket entry with no listener, without a timing race.
|
||||
fs.renameSync(socketPath, socketPath + ".stale");
|
||||
await close(agent);
|
||||
fs.renameSync(socketPath + ".stale", socketPath);
|
||||
assert.ok(fs.statSync(socketPath).isSocket());
|
||||
}
|
||||
process.env.SSH_AUTH_SOCK = socketPath;
|
||||
const sshClients = new Set();
|
||||
const forwardingSockets = new Set();
|
||||
const methods = [];
|
||||
let shells = 0;
|
||||
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
|
||||
sshClients.add(client);
|
||||
client.on("error", () => {});
|
||||
client.on("close", () => sshClients.delete(client));
|
||||
client.on("tcpip", (accept, reject, info) => {
|
||||
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
|
||||
const socket = net.connect(info.destPort, info.destIP, () => {
|
||||
const stream = accept();
|
||||
stream.on("error", () => socket.destroy());
|
||||
stream.pipe(socket).pipe(stream);
|
||||
});
|
||||
forwardingSockets.add(socket);
|
||||
socket.on("close", () => forwardingSockets.delete(socket));
|
||||
socket.on("error", () => reject());
|
||||
});
|
||||
client.on("authentication", (ctx) => {
|
||||
methods.push(ctx.method);
|
||||
if (ctx.method === "password" && stall) return;
|
||||
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
|
||||
else ctx.reject(["publickey", "password"]);
|
||||
});
|
||||
client.on("ready", () => client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("pty", (acceptPty) => acceptPty());
|
||||
session.on("shell", (acceptShell) => {
|
||||
shells += 1;
|
||||
acceptShell().write("fixture ready\r\n");
|
||||
});
|
||||
}));
|
||||
});
|
||||
await listen(ssh, 0, "127.0.0.1");
|
||||
const handlers = new Map();
|
||||
const sent = [];
|
||||
bridge.init({ sessions: new Map(), electronModule: {} });
|
||||
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
|
||||
const event = { sender: { id: senderId, isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }) } };
|
||||
let guard;
|
||||
try {
|
||||
const start = handlers.get("netcatty:start")(event, {
|
||||
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
|
||||
username: "fixture", password, authMethod: "auto", useSshAgent,
|
||||
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
...(jump ? { jumpHosts: [{
|
||||
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
|
||||
password, authMethod: "auto", useSshAgent,
|
||||
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
|
||||
}] } : {}),
|
||||
});
|
||||
let error;
|
||||
try {
|
||||
await Promise.race([start, new Promise((_, reject) => {
|
||||
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
|
||||
})]);
|
||||
} catch (err) { error = err; }
|
||||
return { error, methods, shells, sent: sent.slice(), agentChecks };
|
||||
} finally {
|
||||
clearTimeout(guard);
|
||||
for (const client of sshClients) client.end();
|
||||
for (const socket of forwardingSockets) socket.destroy();
|
||||
for (const client of agentClients) client.destroy();
|
||||
if (agent.listening) await close(agent);
|
||||
await close(ssh);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, options] of [
|
||||
["stale agent", {}],
|
||||
["disabled agent", { useSshAgent: false }],
|
||||
["healthy empty agent", { stale: false }],
|
||||
]) {
|
||||
await t.test(`${name} still opens a real shell using the saved password`, async () => {
|
||||
const result = await run(options);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
|
||||
});
|
||||
}
|
||||
for (const [name, password, methods] of [
|
||||
["agent without other credentials", undefined, ["none"]],
|
||||
["incorrect password", "wrong-password", ["none", "password"]],
|
||||
]) {
|
||||
await t.test(`${name} still reports a real authentication failure`, async () => {
|
||||
// null deliberately omits a password rather than taking run's default.
|
||||
const result = await run({ password: password ?? null });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.match(result.error.message, /All configured authentication methods failed/);
|
||||
assert.deepEqual(result.methods, methods);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
|
||||
});
|
||||
}
|
||||
await t.test("stalled password fallback retains its authentication deadline", async () => {
|
||||
const result = await run({ stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
assert.ok(result.sent.some(({ channel, payload }) => (
|
||||
channel === "netcatty:exit" && payload.reason === "timeout"
|
||||
)));
|
||||
});
|
||||
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
|
||||
const result = await run({ jump: true });
|
||||
assert.equal(result.error, undefined);
|
||||
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
|
||||
assert.equal(result.shells, 1);
|
||||
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
|
||||
});
|
||||
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
|
||||
const result = await run({ jump: true, password: "wrong-password" });
|
||||
assert.equal(result.error?.level, "client-authentication");
|
||||
assert.equal(result.error?.isJumpHostAuthError, true);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
|
||||
const result = await run({ jump: true, stall: true });
|
||||
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
|
||||
assert.deepEqual(result.methods, ["none", "password"]);
|
||||
assert.equal(result.shells, 0);
|
||||
});
|
||||
});
|
||||
@@ -813,7 +813,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.once('error', (err) => {
|
||||
conn.on('error', (err) => {
|
||||
// ssh2 continues to the next auth method after agent errors.
|
||||
if (err.level === 'agent') {
|
||||
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
|
||||
return;
|
||||
}
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearAuthReadyTimer();
|
||||
|
||||
@@ -2481,6 +2481,16 @@ function createStartSessionApi(ctx) {
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// ssh2 reports agent failures before trying the next auth method.
|
||||
// Keep the connection and its authentication deadline alive.
|
||||
if (err.level === "agent") {
|
||||
log("non-fatal agent auth error (will try next method)", {
|
||||
sessionId,
|
||||
hostname: options.hostname,
|
||||
error: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearAuthReadyTimer();
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
|
||||
Reference in new issue
Block a user