[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
265
electron/bridges/sshBridge.connectionTimeout.test.cjs
Normal file
265
electron/bridges/sshBridge.connectionTimeout.test.cjs
Normal file
@@ -0,0 +1,265 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user