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
2186 lines
73 KiB
JavaScript
2186 lines
73 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const Module = require("node:module");
|
|
const { abortPendingBoot } = require("./sessionBootEpoch.cjs");
|
|
|
|
const sshConnectionPool = require("./sshConnectionPool.cjs");
|
|
const {
|
|
createTransport,
|
|
borrowTransport,
|
|
createConnectionRef,
|
|
acquireConnectionRef,
|
|
releaseConnectionRef,
|
|
beginTransportDial,
|
|
completeTransportDial,
|
|
buildConnectionReuseEndpoint,
|
|
resetSshTransportRegistryForTests,
|
|
} = sshConnectionPool;
|
|
|
|
// Load sshBridge with a mocked ssh2 module so we can observe whether a *new*
|
|
// SSH client is constructed (a fresh connection) versus an existing connection
|
|
// being reused for a new shell channel (issue #1204).
|
|
function loadBridgeWithMockedSsh2(t, {
|
|
connectReady = false,
|
|
remoteVer = "OpenSSH_9.0",
|
|
trackShellPids = false,
|
|
beforeShellPidScanResult,
|
|
} = {}) {
|
|
const bridgePath = require.resolve("./sshBridge.cjs");
|
|
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
|
|
const originalLoad = Module._load;
|
|
let clientConstructCount = 0;
|
|
|
|
class MockSSHClient extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this._sock = {
|
|
destroyed: false,
|
|
setTimeout() {},
|
|
setNoDelay() {},
|
|
};
|
|
this._remoteVer = remoteVer;
|
|
this.openedShells = [];
|
|
this.ended = 0;
|
|
}
|
|
connect() {
|
|
clientConstructCount += 1;
|
|
if (connectReady) {
|
|
setImmediate(() => {
|
|
this.emit("connect");
|
|
this.emit("handshake");
|
|
this.emit("ready");
|
|
});
|
|
return;
|
|
}
|
|
// We never want the reuse test to reach a real connect; if it does the
|
|
// test asserts on clientConstructCount and fails clearly.
|
|
setImmediate(() => this.emit("error", new Error("unexpected fresh connect")));
|
|
}
|
|
end() { this.ended += 1; }
|
|
destroy() {}
|
|
exec(_command, callback) {
|
|
if (!trackShellPids) {
|
|
callback?.(new Error("exec unavailable"));
|
|
return;
|
|
}
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
const pids = this.openedShells
|
|
.map((_shell, index) => `${(index + 1) * 111} 1`)
|
|
.join("\n");
|
|
setImmediate(() => {
|
|
beforeShellPidScanResult?.();
|
|
stream.emit("data", Buffer.from(`${pids}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
}
|
|
shell(_pty, _options, callback) {
|
|
const stream = makeStream();
|
|
this.openedShells.push(stream);
|
|
setImmediate(() => callback(null, stream));
|
|
}
|
|
}
|
|
|
|
Module._load = function patchedLoad(request, parent, isMain) {
|
|
if (request === "ssh2") {
|
|
return {
|
|
Client: MockSSHClient,
|
|
utils: { parseKey: () => new Error("no key") },
|
|
};
|
|
}
|
|
if (request === "ssh2/lib/agent.js") {
|
|
return { BaseAgent: class BaseAgent {} };
|
|
}
|
|
if (request === "ssh2/lib/protocol/keyParser.js") {
|
|
return { parseKey: () => new Error("no key") };
|
|
}
|
|
if (request === "electron") {
|
|
return {
|
|
app: {
|
|
getPath: (name) => `/tmp/netcatty-test-${name}`,
|
|
isReady: () => true,
|
|
getName: () => "netcatty",
|
|
getVersion: () => "0.0.0",
|
|
},
|
|
ipcMain: { handle() {}, on() {}, removeHandler() {} },
|
|
BrowserWindow: class BrowserWindow {},
|
|
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
|
shell: { openPath: async () => "" },
|
|
nativeTheme: { shouldUseDarkColors: false },
|
|
webContents: { fromId: () => null },
|
|
};
|
|
}
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
|
|
const extraCached = [
|
|
require.resolve("./netcattyAgent.cjs"),
|
|
require.resolve("./zmodemHelper.cjs"),
|
|
require.resolve("./sshBridge/startSession.cjs"),
|
|
];
|
|
delete require.cache[bridgePath];
|
|
delete require.cache[authHelperPath];
|
|
for (const extra of extraCached) delete require.cache[extra];
|
|
const bridge = require("./sshBridge.cjs");
|
|
|
|
t.after(() => {
|
|
delete require.cache[bridgePath];
|
|
delete require.cache[authHelperPath];
|
|
for (const extra of extraCached) delete require.cache[extra];
|
|
Module._load = originalLoad;
|
|
});
|
|
|
|
return { bridge, getClientConstructCount: () => clientConstructCount };
|
|
}
|
|
|
|
test("simultaneous normal opens identify both shells before sharing one SSH connection", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
trackShellPids: true,
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.50",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
const [first, second] = await Promise.all([
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
|
|
]);
|
|
|
|
assert.equal(first.sessionId, "normal-1");
|
|
assert.equal(second.sessionId, "normal-2");
|
|
assert.equal(getClientConstructCount(), 1);
|
|
assert.equal(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
|
assert.equal(sessions.get("normal-1").connRef.count, 2);
|
|
assert.equal(sessions.get("normal-1").shellPid, "111");
|
|
assert.equal(sessions.get("normal-2").shellPid, "222");
|
|
});
|
|
|
|
test("simultaneous normal opens use separate connections when shell identity is unavailable", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.54",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await Promise.all([
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
|
|
]);
|
|
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
|
});
|
|
|
|
test("simultaneous normal opens do not trust a replacement session after identity discovery", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const sessions = new Map();
|
|
let replaced = false;
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
trackShellPids: true,
|
|
beforeShellPidScanResult: () => {
|
|
if (replaced) return;
|
|
replaced = true;
|
|
const original = sessions.get("normal-1");
|
|
sessions.set("normal-1", { ...original, shellPid: "999" });
|
|
},
|
|
});
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.55",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await Promise.all([
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-1" }),
|
|
start({ sender: makeSender() }, { ...options, sessionId: "normal-2" }),
|
|
]);
|
|
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("normal-1").conn, sessions.get("normal-2").conn);
|
|
});
|
|
|
|
test("reuseTransport false makes simultaneous same-host opens dial independently", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.51",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
reuseTransport: false,
|
|
};
|
|
|
|
await Promise.all([
|
|
start({ sender: makeSender() }, { ...options, sessionId: "fresh-1" }),
|
|
start({ sender: makeSender() }, { ...options, sessionId: "fresh-2" }),
|
|
]);
|
|
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("fresh-1").conn, sessions.get("fresh-2").conn);
|
|
});
|
|
|
|
test("a sequential ordinary open with reuse disabled bypasses the live same-host transport", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.52",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
await start({ sender: makeSender() }, {
|
|
...options,
|
|
sessionId: "second",
|
|
reuseTransport: false,
|
|
});
|
|
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("first").conn, sessions.get("second").conn);
|
|
});
|
|
|
|
test("an ordinary open with reuse disabled bypasses an idle same-host transport", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "10.0.0.53",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const firstTransport = first.connRef;
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "idle");
|
|
|
|
await start({ sender: makeSender() }, {
|
|
...options,
|
|
sessionId: "second",
|
|
reuseTransport: false,
|
|
});
|
|
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(firstTransport.conn, sessions.get("second").conn);
|
|
});
|
|
|
|
test("TERM-SSHD close does not idle-park, so reconnect dials a fresh connection", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
remoteVer: "TERM-SSHD",
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "blj.yd.com.cn",
|
|
username: "test",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const firstTransport = first.connRef;
|
|
assert.equal(firstTransport.allowIdlePark, false);
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "dead");
|
|
assert.equal(first.conn.ended, 1);
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
|
|
assert.equal(getClientConstructCount(), 2, "second open must not reuse a TERM-SSHD transport");
|
|
assert.notEqual(sessions.get("second").conn, first.conn);
|
|
});
|
|
|
|
test("idle-park reconnect falls back to a fresh dial when the reused shell exits immediately", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
remoteVer: "CustomBastion_1.0",
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
sshReusedShellLivenessMs: 25,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const parkedConn = first.conn;
|
|
const firstTransport = first.connRef;
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "idle", "unknown banners still park until proven broken");
|
|
|
|
parkedConn.shell = (_pty, _shellOpts, callback) => {
|
|
const stream = makeStream();
|
|
parkedConn.openedShells.push(stream);
|
|
setImmediate(() => {
|
|
callback(null, stream);
|
|
setImmediate(() => {
|
|
stream.emit("exit", 0);
|
|
stream.emit("close");
|
|
});
|
|
});
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
|
|
assert.equal(getClientConstructCount(), 2, "dead parked shell must fall back to a fresh connection");
|
|
assert.notEqual(sessions.get("second").conn, parkedConn);
|
|
assert.equal(firstTransport.state, "dead");
|
|
|
|
const second = sessions.get("second");
|
|
const secondTransport = second.connRef;
|
|
second.stream.emit("close");
|
|
assert.equal(secondTransport.state, "dead", "endpoint is denylisted so the next close does not park");
|
|
});
|
|
|
|
test("TERM-SSHD last shell with SFTP still open dials a fresh shell", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
remoteVer: "TERM-SSHD",
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "blj.yd.com.cn",
|
|
username: "test",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const firstTransport = first.connRef;
|
|
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, firstTransport);
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "live");
|
|
assert.equal(firstTransport.allowShellReuse, false);
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("second").conn, first.conn);
|
|
});
|
|
|
|
test("SFTP-held reconnect falls back when the reused shell exits immediately", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
remoteVer: "CustomBastion_1.0",
|
|
});
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
sshReusedShellLivenessMs: 25,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const parkedConn = first.conn;
|
|
const firstTransport = first.connRef;
|
|
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, firstTransport);
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "live");
|
|
assert.ok(firstTransport.pendingShellReconnectRisk);
|
|
|
|
parkedConn.shell = (_pty, _shellOpts, callback) => {
|
|
const stream = makeStream();
|
|
parkedConn.openedShells.push(stream);
|
|
setImmediate(() => {
|
|
callback(null, stream);
|
|
setImmediate(() => {
|
|
stream.emit("exit", 0);
|
|
stream.emit("close");
|
|
});
|
|
});
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
|
|
assert.equal(getClientConstructCount(), 2);
|
|
assert.notEqual(sessions.get("second").conn, parkedConn);
|
|
assert.equal(firstTransport.allowShellReuse, false);
|
|
});
|
|
|
|
test("idle-park reconnect after last shell closes skips post-open PID discovery", async (t) => {
|
|
// After the sole interactive shell returns its lease, the next open reuses the
|
|
// parked transport. Post-open discovery exec has no sibling PIDs to
|
|
// disambiguate and can tear down bastion sessions (issue #2923).
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "bastion.qzsec.example",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first" });
|
|
const first = sessions.get("first");
|
|
const parkedConn = first.conn;
|
|
const firstTransport = first.connRef;
|
|
first.stream.emit("close");
|
|
assert.equal(firstTransport.state, "idle");
|
|
|
|
let execCalls = 0;
|
|
parkedConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
callback(new Error("idle reconnect must not open discovery exec"));
|
|
};
|
|
const shellsBefore = parkedConn.openedShells.length;
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "second" });
|
|
|
|
assert.equal(getClientConstructCount(), 1, "second open reuses the parked transport");
|
|
assert.equal(parkedConn.openedShells.length, shellsBefore + 1);
|
|
assert.equal(execCalls, 0, "must not open discovery exec after sole-shell reconnect");
|
|
const second = sessions.get("second");
|
|
assert.equal(second.blockUntargetedCwdProbe, true);
|
|
assert.notEqual(second.allowCwdRecovery, true);
|
|
|
|
second.pendingCwdRecoveryAfterUserCommand = true;
|
|
second.stream.emit("data", Buffer.from("command output\r\n"));
|
|
assert.equal(second.allowCwdRecovery, true);
|
|
|
|
// A second ordinary reconnect can arrive while the first risk-marked shell
|
|
// is still live but unassigned. It must not reopen PID discovery during the
|
|
// connection path either; both sessions stay fail-closed instead.
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "third" });
|
|
const third = sessions.get("third");
|
|
assert.equal(execCalls, 0);
|
|
assert.equal(third.blockUntargetedCwdProbe, true);
|
|
assert.equal(third.parkedReconnectRisk.hasUnknownOldShell, true);
|
|
});
|
|
|
|
for (const leaseKind of ["sftp", "forward"]) {
|
|
test(`first shell on a ${leaseKind}-only transport keeps cwd discovery enabled`, async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: `${leaseKind}-only.example`,
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
const conn = makeReusableConn();
|
|
const transport = createTransport({
|
|
conn,
|
|
endpoint: buildConnectionReuseEndpoint(options),
|
|
});
|
|
borrowTransport(transport, {
|
|
kind: leaseKind,
|
|
holder: { id: `${leaseKind}-holder` },
|
|
});
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
|
|
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sessions.get("first-shell").connRef, transport);
|
|
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
|
|
});
|
|
}
|
|
|
|
test("shell joining an SFTP-led initial dial keeps cwd discovery enabled", async (t) => {
|
|
const originalWaitForTransportDial = sshConnectionPool.waitForTransportDial;
|
|
let observeJoin;
|
|
const joinedDial = new Promise((resolve) => { observeJoin = resolve; });
|
|
sshConnectionPool.waitForTransportDial = (coordination, ...args) => {
|
|
if (coordination?.role === "join") observeJoin();
|
|
return originalWaitForTransportDial(coordination, ...args);
|
|
};
|
|
t.after(() => { sshConnectionPool.waitForTransportDial = originalWaitForTransportDial; });
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "sftp-led.example",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
const endpoint = buildConnectionReuseEndpoint(options);
|
|
const coordination = beginTransportDial(endpoint, { kind: "channel" });
|
|
const pendingStart = start({ sender: makeSender() }, { ...options, sessionId: "first-shell" });
|
|
await joinedDial;
|
|
|
|
const conn = makeReusableConn();
|
|
const transport = createTransport({ conn, endpoint });
|
|
borrowTransport(transport, { kind: "sftp", holder: { id: "sftp-holder" } });
|
|
completeTransportDial(coordination, transport);
|
|
await pendingStart;
|
|
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sessions.get("first-shell").connRef, transport);
|
|
assert.notEqual(sessions.get("first-shell").blockUntargetedCwdProbe, true);
|
|
});
|
|
|
|
test("last shell close stays protected when an SFTP lease keeps the transport live", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const options = {
|
|
hostname: "shell-plus-sftp.example",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
};
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "old-shell" });
|
|
const oldSession = sessions.get("old-shell");
|
|
const transport = oldSession.connRef;
|
|
oldSession.shellPid = "111";
|
|
acquireConnectionRef({ id: "sftp-holder", __sshLeaseKind: "sftp" }, transport);
|
|
oldSession.stream.emit("close");
|
|
assert.equal(transport.state, "live");
|
|
|
|
await start({ sender: makeSender() }, { ...options, sessionId: "new-shell" });
|
|
|
|
assert.equal(getClientConstructCount(), 1);
|
|
assert.equal(sessions.get("new-shell").connRef, transport);
|
|
assert.equal(sessions.get("new-shell").blockUntargetedCwdProbe, true);
|
|
assert.deepEqual(sessions.get("new-shell").parkedReconnectRisk, {
|
|
oldShellPids: ["111"],
|
|
hasUnknownOldShell: false,
|
|
});
|
|
});
|
|
|
|
function makeSender() {
|
|
return {
|
|
id: 1,
|
|
isDestroyed: () => false,
|
|
sent: [],
|
|
send(channel, payload) { this.sent.push({ channel, payload }); },
|
|
};
|
|
}
|
|
|
|
function getConnectionReuseFallbackEvents(sender) {
|
|
return sender.sent.filter((m) => m.channel === "netcatty:connection-reuse:fallback");
|
|
}
|
|
|
|
test.beforeEach(() => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
});
|
|
|
|
test.afterEach(() => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
});
|
|
|
|
// A fake ssh2 shell channel.
|
|
function makeStream() {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.closed = false;
|
|
stream.write = () => true;
|
|
stream.signal = () => {};
|
|
stream.close = () => { stream.closed = true; stream.emit("close"); };
|
|
return stream;
|
|
}
|
|
|
|
// A fake authenticated ssh2 connection that hands out shell channels.
|
|
function makeReusableConn() {
|
|
const conn = new EventEmitter();
|
|
conn._sock = { destroyed: false };
|
|
conn._remoteVer = "OpenSSH_9.0";
|
|
conn.ended = 0;
|
|
conn.openedShells = [];
|
|
conn.openedShellOptions = [];
|
|
conn.end = () => { conn.ended += 1; };
|
|
conn.destroy = () => {};
|
|
conn.shell = (_opts, shellOpts, cb) => {
|
|
const stream = makeStream();
|
|
conn.openedShells.push(stream);
|
|
conn.openedShellOptions.push(shellOpts);
|
|
// ssh2 invokes the callback asynchronously.
|
|
setImmediate(() => cb(null, stream));
|
|
};
|
|
return conn;
|
|
}
|
|
|
|
function makePidTrackingReusableConn({ delayFirstNewPid = false } = {}) {
|
|
const conn = makeReusableConn();
|
|
conn.shellPidSnapshots = [];
|
|
conn.sourceShellVisible = true;
|
|
let delayed = false;
|
|
conn.exec = (_command, callback) => {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
let visibleShellCount = conn.openedShells.length;
|
|
if (delayFirstNewPid && conn.openedShells.length > 0 && !delayed) {
|
|
delayed = true;
|
|
visibleShellCount -= 1;
|
|
}
|
|
const snapshot = [
|
|
...(conn.sourceShellVisible ? ["111"] : []),
|
|
...Array.from(
|
|
{ length: visibleShellCount },
|
|
(_value, index) => String((index + 2) * 111),
|
|
),
|
|
];
|
|
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
|
conn.shellPidSnapshots.push(snapshot);
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
return conn;
|
|
}
|
|
|
|
function makeUnavailableDiscoveryConn() {
|
|
const conn = makeReusableConn();
|
|
conn.discoveryExecCalls = 0;
|
|
conn.exec = (_command, callback) => {
|
|
conn.discoveryExecCalls += 1;
|
|
callback(new Error("exec channels disabled"));
|
|
};
|
|
return conn;
|
|
}
|
|
|
|
function makeFailedDiscoveryCommandConn() {
|
|
const conn = makeReusableConn();
|
|
conn.discoveryExecCalls = 0;
|
|
conn.exec = (_command, callback) => {
|
|
conn.discoveryExecCalls += 1;
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from("111\n"));
|
|
stream.emit("close", 127);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
return conn;
|
|
}
|
|
|
|
// A reusable connection whose shell callback is held until released, so a test
|
|
// can simulate the source tab closing while conn.shell() is still pending.
|
|
function makeDeferredShellConn() {
|
|
const conn = new EventEmitter();
|
|
conn._sock = { destroyed: false };
|
|
conn._remoteVer = "OpenSSH_9.0";
|
|
conn.ended = 0;
|
|
conn.openedShells = [];
|
|
conn._pending = [];
|
|
conn.end = () => { conn.ended += 1; };
|
|
conn.destroy = () => {};
|
|
conn.shell = (_opts, _shellOpts, cb) => {
|
|
conn._pending.push(cb);
|
|
};
|
|
conn.flushShell = () => {
|
|
const cbs = conn._pending.splice(0);
|
|
for (const cb of cbs) {
|
|
const stream = makeStream();
|
|
conn.openedShells.push(stream);
|
|
cb(null, stream);
|
|
}
|
|
};
|
|
return conn;
|
|
}
|
|
|
|
// Build a live source session as if it had connected normally, including the
|
|
// registry transport lease and the recorded endpoint used for reuse target
|
|
// matching.
|
|
function makeSourceSession(conn, endpoint) {
|
|
const session = {
|
|
conn,
|
|
stream: makeStream(),
|
|
chainConnections: [],
|
|
webContentsId: 1,
|
|
zmodemSentry: { cancel() {} },
|
|
hostname: endpoint.hostname,
|
|
username: endpoint.username,
|
|
_reuseEndpoint: {
|
|
hostname: endpoint.hostname,
|
|
port: endpoint.port || 22,
|
|
username: endpoint.username,
|
|
...(Array.isArray(endpoint.jumpHosts) ? { jumpHosts: endpoint.jumpHosts } : {}),
|
|
},
|
|
};
|
|
createConnectionRef(session, conn, []);
|
|
return session;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
test("Copy Tab reuses the source connection instead of dialing fresh", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
|
|
// Seed a live source session as if it had connected normally.
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const sender = makeSender();
|
|
|
|
const result = await start(
|
|
{ sender },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
port: 22,
|
|
sourceSessionId: "source",
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sessionId, "copy");
|
|
// No new SSH client was constructed/connected — the existing connection was reused.
|
|
assert.equal(getClientConstructCount(), 0);
|
|
// A new shell channel was opened on the source connection.
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
// The new session is tracked and shares the source's connRef (count bumped).
|
|
const copy = sessions.get("copy");
|
|
assert.ok(copy, "copy session should be registered");
|
|
assert.equal(copy.conn, sourceConn);
|
|
assert.equal(copy.connRef.count, 2);
|
|
|
|
// A 'connected' progress event was emitted for the renderer.
|
|
const progress = sender.sent.filter((m) => m.channel === "netcatty:chain:progress");
|
|
assert.ok(progress.some((m) => m.payload.status === "connected"));
|
|
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0, "successful reuse should not emit fallback");
|
|
});
|
|
|
|
test("reuseTransport false bypasses a matching live source and connects fresh", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, { connectReady: true });
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "fresh",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
port: 22,
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
sourceSessionId: "source",
|
|
reuseTransport: false,
|
|
},
|
|
);
|
|
|
|
assert.equal(getClientConstructCount(), 1);
|
|
assert.equal(sourceConn.openedShells.length, 0);
|
|
assert.notEqual(sessions.get("fresh").conn, sourceConn);
|
|
});
|
|
|
|
test("Copy Tab records a distinct remote shell for each shared terminal", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makePidTrackingReusableConn();
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
// Live tabs normally already know their login shellPid (cwd / sessionOps).
|
|
// Copy Tab must not burn a discovery exec before opening the reused shell.
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(sourceConn.shellPidSnapshots.length >= 1);
|
|
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
|
|
assert.equal(source.shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab skips POSIX shell discovery for network devices", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = () => { execCalls += 1; };
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
skipShellPidDiscovery: true,
|
|
},
|
|
);
|
|
|
|
assert.equal(execCalls, 0);
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
assert.ok(sessions.get("copy"));
|
|
});
|
|
|
|
test("Copy Tab with jump hosts still discovers shell PIDs after a short delay", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makePidTrackingReusableConn();
|
|
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
jumpHosts,
|
|
});
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
jumpHosts,
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(sourceConn.shellPidSnapshots.length >= 1, "jump-host reuse must still run post-open discovery");
|
|
assert.equal(source.shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
});
|
|
|
|
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
// First post-open scan is rate-limited; later scans succeed.
|
|
if (execCalls === 1) {
|
|
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
return;
|
|
}
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
jumpHosts,
|
|
});
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
jumpHosts,
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(execCalls >= 2, "post-open discovery must retry after channelOpen too offen");
|
|
assert.equal(source.shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab retries rate-limited PID discovery for direct bastion targets", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
// Direct bastion (no jumpHosts): first post-open scan is rate-limited.
|
|
if (execCalls === 1) {
|
|
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
return;
|
|
}
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(execCalls >= 2, "direct bastion reuse must retry rate-limited PID discovery");
|
|
assert.equal(sessions.get("source").shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab claims an unambiguous leftover PID when the source never recorded one", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// First post-open snapshot still only sees the source shell; later scans
|
|
// include the copied shell so waitForNewInteractiveShellPid can finish.
|
|
const snapshot = execCalls === 1 ? ["111"] : ["111", "222"];
|
|
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(execCalls >= 2);
|
|
assert.equal(sessions.get("source").shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab waits before claiming a sole PID after the source closes", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
|
const sessions = new Map();
|
|
const sourceConn = makeDeferredShellConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// Source tab already closed: early scans can still list the dying source
|
|
// shell before the copied shell is visible.
|
|
const snapshot = execCalls < 3 ? ["111"] : ["222"];
|
|
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
});
|
|
sessions.set("source", source);
|
|
|
|
terminalBridge.init({ sessions, electronModule: {} });
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const startPromise = start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
|
sourceConn.flushShell();
|
|
await startPromise;
|
|
|
|
assert.ok(execCalls >= 3, "must wait through the dying-source-only scans");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab disambiguates a two-PID first scan when the source never recorded shellPid", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// OSC 7 sources skip the cwd probe, so shellPid is unset. The copied shell
|
|
// is already visible in the first post-open scan — no gradual appearance.
|
|
// Ages (etimes seconds; higher = older) distinguish source from copy.
|
|
const pids = "111 40\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(execCalls, 1, "must not require waitForNew retries once both PIDs are visible");
|
|
assert.equal(sessions.get("source").shellPid, "111");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab uses process age when PID wrap makes the copy numerically smaller", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sourceConn.exec = (_command, callback) => {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// After PID wrap the copied shell can receive a lower numeric PID while
|
|
// still being the younger process (etimes 1 vs 90).
|
|
const pids = "50 1\n4000 90\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(sessions.get("source").shellPid, "4000");
|
|
assert.equal(sessions.get("copy").shellPid, "50");
|
|
});
|
|
|
|
test("Copy Tab leaves PIDs unassigned when both shells share the same etimes second", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sourceConn.exec = (_command, callback) => {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// Same whole-second etimes tick: age inequality cannot separate them, and
|
|
// numeric PID order is unsafe after wrap — leave both unassigned.
|
|
const pids = "111 1\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(sessions.get("source").shellPid, undefined);
|
|
assert.equal(sessions.get("copy").shellPid, undefined);
|
|
});
|
|
|
|
test("Copy Tab leaves PIDs unassigned when remote ps lacks etimes", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sourceConn.exec = (_command, callback) => {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// Age-less "pid" lines only — hosts without etimes. Do not guess via PID
|
|
// order; a wrap between source and copy would swap the assignment.
|
|
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(sessions.get("source").shellPid, undefined);
|
|
assert.equal(sessions.get("copy").shellPid, undefined);
|
|
});
|
|
|
|
test("Copy Tab reconciles an untracked source even when another sibling PID is known", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sourceConn.exec = (_command, callback) => {
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
// Tracked sibling 100 plus untracked source 200 and new copy 300.
|
|
const pids = "100 120\n200 40\n300 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
});
|
|
const tracked = {
|
|
conn: sourceConn,
|
|
stream: makeStream(),
|
|
chainConnections: [],
|
|
webContentsId: 1,
|
|
zmodemSentry: { cancel() {} },
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
_reuseEndpoint: source._reuseEndpoint,
|
|
shellPid: "100",
|
|
};
|
|
acquireConnectionRef(tracked, source.connRef);
|
|
sessions.set("tracked", tracked);
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(sessions.get("tracked").shellPid, "100");
|
|
assert.equal(sessions.get("source").shellPid, "200");
|
|
assert.equal(sessions.get("copy").shellPid, "300");
|
|
});
|
|
|
|
test("Copy Tab retries bastion channelOpen too offen before falling back", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
if (shellAttempts === 1) {
|
|
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
|
return;
|
|
}
|
|
const stream = makeStream();
|
|
sourceConn.openedShells.push(stream);
|
|
setImmediate(() => cb(null, stream));
|
|
};
|
|
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
jumpHosts,
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const sender = makeSender();
|
|
const result = await start(
|
|
{ sender },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
jumpHosts,
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sessionId, "copy");
|
|
assert.equal(shellAttempts, 2);
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
|
});
|
|
|
|
test("Copy Tab keeps reusing when a bastion rate limit outlasts the legacy retry burst", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
if (shellAttempts <= 4) {
|
|
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
|
return;
|
|
}
|
|
const stream = makeStream();
|
|
sourceConn.openedShells.push(stream);
|
|
setImmediate(() => cb(null, stream));
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const sender = makeSender();
|
|
const result = await start(
|
|
{ sender },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sessionId, "copy");
|
|
assert.equal(shellAttempts, 5);
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
|
});
|
|
|
|
test("ordinary parked reuse keeps the legacy retry bound", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "ordinary",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
),
|
|
/unexpected fresh connect/,
|
|
);
|
|
|
|
// The ordinary path can try the same live transport through its parked and
|
|
// coordinated-reuse stages. Each stage keeps the legacy four-attempt bound.
|
|
assert.equal(shellAttempts, 8);
|
|
assert.equal(getClientConstructCount(), 1);
|
|
});
|
|
|
|
test("cancelling Copy Tab during rate-limit backoff stops retries and keeps the source alive", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
setImmediate(() => {
|
|
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
setImmediate(() => abortPendingBoot("copy", 1));
|
|
});
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
bootEpoch: 1,
|
|
sshChannelOpenRateLimitBackoffMs: 50,
|
|
},
|
|
),
|
|
/aborted/,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
|
|
assert.equal(shellAttempts, 1);
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sourceConn.ended, 0);
|
|
assert.equal(source.connRef.count, 1);
|
|
});
|
|
|
|
test("cancelling Copy Tab during reused-shell liveness does not register a stale session", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sourceConn._remoteVer = "CustomBastion_1.0";
|
|
let openedStream = null;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
openedStream = makeStream();
|
|
sourceConn.openedShells.push(openedStream);
|
|
setImmediate(() => {
|
|
cb(null, openedStream);
|
|
setTimeout(() => abortPendingBoot("copy", 1), 5);
|
|
});
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
source.connRef.pendingShellReconnectRisk = {
|
|
oldShellPids: [],
|
|
hasUnknownOldShell: true,
|
|
};
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
bootEpoch: 1,
|
|
sshReusedShellLivenessMs: 100,
|
|
},
|
|
),
|
|
/aborted/,
|
|
);
|
|
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sessions.has("copy"), false);
|
|
assert.equal(openedStream.closed, true);
|
|
assert.equal(sourceConn.ended, 0);
|
|
assert.equal(source.connRef.count, 1);
|
|
});
|
|
|
|
test("cancelling ordinary parked reuse does not start a fresh login", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
setImmediate(() => {
|
|
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
setImmediate(() => abortPendingBoot("ordinary", 1));
|
|
});
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "ordinary",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
bootEpoch: 1,
|
|
sshChannelOpenRateLimitBackoffMs: 50,
|
|
},
|
|
),
|
|
/aborted/,
|
|
);
|
|
|
|
assert.equal(shellAttempts, 1);
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sourceConn.ended, 0);
|
|
assert.equal(source.connRef.count, 1);
|
|
});
|
|
|
|
test("an abandoned Copy Tab open blocks overlapping reuse until the raw callback settles", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeDeferredShellConn();
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
sessions.set("source", source);
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
const firstStart = start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-1",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
bootEpoch: 1,
|
|
},
|
|
);
|
|
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
assert.equal(sourceConn._pending.length, 1);
|
|
abortPendingBoot("copy-1", 1);
|
|
await assert.rejects(firstStart, /aborted/);
|
|
assert.equal(source.connRef.pendingAbandonedShellOpens, 1);
|
|
assert.equal(sourceConn._pending.length, 1);
|
|
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-2",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
bootEpoch: 1,
|
|
},
|
|
),
|
|
/unexpected fresh connect/,
|
|
);
|
|
assert.equal(sourceConn._pending.length, 1, "second copy must not overlap the abandoned open");
|
|
|
|
sourceConn.flushShell();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(source.connRef.pendingAbandonedShellOpens, undefined);
|
|
|
|
const thirdStart = start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-3",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
bootEpoch: 1,
|
|
skipShellPidDiscovery: true,
|
|
},
|
|
);
|
|
for (let attempt = 0; attempt < 20 && sourceConn._pending.length === 0; attempt += 1) {
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
assert.equal(sourceConn._pending.length, 1);
|
|
sourceConn.flushShell();
|
|
const result = await thirdStart;
|
|
|
|
assert.equal(result.sessionId, "copy-3");
|
|
assert.equal(getClientConstructCount(), 1);
|
|
assert.equal(sourceConn.ended, 0);
|
|
});
|
|
|
|
test("a shared connection error during Copy Tab backoff stops queued retries", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
let shellAttempts = 0;
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
shellAttempts += 1;
|
|
setImmediate(() => {
|
|
cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
setImmediate(() => sourceConn.emit("error", new Error("transport lost")));
|
|
});
|
|
};
|
|
sessions.set("source", makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
}));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await assert.rejects(
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 50,
|
|
},
|
|
),
|
|
/unexpected fresh connect/,
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
|
|
assert.equal(shellAttempts, 1);
|
|
assert.equal(getClientConstructCount(), 1);
|
|
});
|
|
|
|
test("Copy Tab opens the shell before PID discovery so bastion rate limits do not burn the session slot", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
const channelOrder = [];
|
|
let execCalls = 0;
|
|
sourceConn.exec = (_command, callback) => {
|
|
execCalls += 1;
|
|
channelOrder.push("exec");
|
|
// Bastions rate-limit rapid session channels. If discovery runs first it
|
|
// burns the budget and every subsequent shell open is rejected.
|
|
if (sourceConn.openedShells.length === 0) {
|
|
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
|
|
return;
|
|
}
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => {};
|
|
const snapshot = ["111", "222"];
|
|
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
|
|
setImmediate(() => {
|
|
stream.emit("data", Buffer.from(pids));
|
|
stream.emit("close", 0);
|
|
});
|
|
callback(null, stream);
|
|
};
|
|
sourceConn.shell = (_opts, _shellOpts, cb) => {
|
|
channelOrder.push("shell");
|
|
if (execCalls > 0 && sourceConn.openedShells.length === 0) {
|
|
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
|
|
return;
|
|
}
|
|
const stream = makeStream();
|
|
sourceConn.openedShells.push(stream);
|
|
setImmediate(() => cb(null, stream));
|
|
};
|
|
const source = makeSourceSession(sourceConn, {
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
});
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const sender = makeSender();
|
|
const result = await start(
|
|
{ sender },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "bastion.example",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sessionId, "copy");
|
|
assert.equal(getClientConstructCount(), 0);
|
|
assert.equal(sourceConn.openedShells.length, 1);
|
|
assert.equal(channelOrder[0], "shell", "shell must open before any discovery exec");
|
|
assert.ok(execCalls >= 1, "PID discovery still runs after the shell opens");
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
|
|
});
|
|
|
|
test("Copy Tab waits briefly when the new remote shell is not visible immediately", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makePidTrackingReusableConn({ delayFirstNewPid: true });
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
);
|
|
|
|
assert.ok(sourceConn.shellPidSnapshots.length >= 2);
|
|
assert.deepEqual(sourceConn.shellPidSnapshots[0], ["111"]);
|
|
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
|
|
assert.equal(sessions.get("copy").shellPid, "222");
|
|
});
|
|
|
|
test("Copy Tab does not retry when remote shell discovery is unavailable", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeUnavailableDiscoveryConn();
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
},
|
|
);
|
|
|
|
assert.equal(sourceConn.discoveryExecCalls, 1);
|
|
assert.equal(sessions.get("source").shellPid, undefined);
|
|
assert.equal(sessions.get("copy").shellPid, undefined);
|
|
});
|
|
|
|
test("Copy Tab does not retry when the remote discovery command fails", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeFailedDiscoveryCommandConn();
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
},
|
|
);
|
|
|
|
assert.equal(sourceConn.discoveryExecCalls, 1);
|
|
assert.equal(sessions.get("copy").shellPid, undefined);
|
|
});
|
|
|
|
test("concurrent Copy Tab requests serialize shell discovery per connection", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makePidTrackingReusableConn();
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
source.shellPid = "111";
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await Promise.all([
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-1",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
),
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-2",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
),
|
|
]);
|
|
|
|
assert.equal(sessions.get("source").shellPid, "111");
|
|
// The macOS network preflight runs before either request joins the shared
|
|
// shell-open queue, so under load either copy may enqueue first. The contract
|
|
// is that both remote shells are identified once and never collide.
|
|
assert.deepEqual(
|
|
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
|
|
new Set(["222", "333"]),
|
|
);
|
|
});
|
|
|
|
test("concurrent copies keep distinct shell IDs when the source closes immediately", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
|
const sessions = new Map();
|
|
const sourceConn = makePidTrackingReusableConn({ delayFirstNewPid: true });
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
source.shellPid = "111";
|
|
const closeSourceStream = source.stream.close;
|
|
source.stream.close = () => {
|
|
sourceConn.sourceShellVisible = false;
|
|
closeSourceStream();
|
|
};
|
|
sessions.set("source", source);
|
|
|
|
terminalBridge.init({ sessions, electronModule: {} });
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const copies = Promise.all([
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-1",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
),
|
|
start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-2",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
sshChannelOpenRateLimitBackoffMs: 1,
|
|
},
|
|
),
|
|
]);
|
|
|
|
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
|
await copies;
|
|
|
|
assert.deepEqual(
|
|
new Set([sessions.get("copy-1").shellPid, sessions.get("copy-2").shellPid]),
|
|
new Set(["222", "333"]),
|
|
);
|
|
});
|
|
|
|
test("Copy Tab preserves the server locale unless the host explicitly overrides it", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-default-locale",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
charset: "UTF-8",
|
|
env: { TERM: "xterm-256color" },
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(sourceConn.openedShellOptions[0].env, {
|
|
COLORTERM: "truecolor",
|
|
TERM: "xterm-256color",
|
|
});
|
|
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy-explicit-locale",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
charset: "UTF-8",
|
|
env: { LANG: "zh_CN.UTF-8" },
|
|
},
|
|
);
|
|
|
|
assert.equal(sourceConn.openedShellOptions[1].env.LANG, "zh_CN.UTF-8");
|
|
});
|
|
|
|
test("closing the reused channel keeps the source connection alive", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
const connRef = source.connRef;
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
await start(
|
|
{ sender: makeSender() },
|
|
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
|
|
);
|
|
|
|
const copy = sessions.get("copy");
|
|
assert.equal(connRef.count, 2);
|
|
|
|
// Simulate the remote shell of the copy exiting: its channel closes.
|
|
copy.stream.emit("close");
|
|
|
|
// The shared connection must NOT be ended — the source is still using it.
|
|
assert.equal(sourceConn.ended, 0);
|
|
assert.equal(connRef.count, 1);
|
|
assert.equal(sessions.has("copy"), false, "copy session cleaned up");
|
|
assert.ok(sessions.has("source"), "source session still alive");
|
|
|
|
// Last release parks the healthy connection for later reuse.
|
|
assert.equal(releaseConnectionRef(sessions.get("source")), false);
|
|
assert.equal(sourceConn.ended, 0);
|
|
assert.equal(connRef.state, "idle");
|
|
});
|
|
|
|
test("skips reuse for X11-forwarding hosts and connects fresh", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
// X11 forwarding is per-channel, so a reused channel would lose it. The
|
|
// bridge must skip reuse and dial a fresh connection instead.
|
|
await assert.rejects(
|
|
() => start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
x11Forwarding: true,
|
|
},
|
|
),
|
|
);
|
|
assert.equal(sourceConn.openedShells.length, 0, "must not reuse the source connection");
|
|
assert.equal(getClientConstructCount(), 1, "should dial a fresh connection for X11");
|
|
});
|
|
|
|
test("source closed while reused shell is pending keeps the connection alive", async (t) => {
|
|
const { bridge } = loadBridgeWithMockedSsh2(t);
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
|
const sessions = new Map();
|
|
const conn = makeDeferredShellConn();
|
|
const source = makeSourceSession(conn, { hostname: "10.0.0.1", username: "alice" });
|
|
const connRef = source.connRef;
|
|
sessions.set("source", source);
|
|
|
|
terminalBridge.init({ sessions, electronModule: {} });
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
// Begin the copy; conn.shell() is deferred. The request pin protects the
|
|
// explicit source across preflight, and the shell-open pin protects the
|
|
// pending channel (count -> 3) before the real session is attached.
|
|
const startPromise = start(
|
|
{ sender: makeSender() },
|
|
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
|
|
);
|
|
await new Promise((r) => setImmediate(r));
|
|
assert.equal(connRef.count, 3, "request and shell-open pins protect the source connection");
|
|
|
|
// Close the source tab while the copy's shell is still opening.
|
|
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
|
assert.equal(conn.ended, 0, "connection must survive for the pending copy");
|
|
assert.equal(connRef.count, 2);
|
|
|
|
// Now let the copy's shell open.
|
|
conn.flushShell();
|
|
const result = await startPromise;
|
|
assert.equal(result.sessionId, "copy");
|
|
assert.equal(conn.ended, 0, "connection still alive for the active copy");
|
|
const copy = sessions.get("copy");
|
|
assert.ok(copy);
|
|
assert.equal(copy.connRef, connRef);
|
|
assert.equal(connRef.count, 1, "count reflects exactly the one remaining channel");
|
|
|
|
// Closing the copy (last holder) parks the healthy connection.
|
|
terminalBridge.closeSession({ sender: {} }, { sessionId: "copy" });
|
|
assert.equal(conn.ended, 0);
|
|
assert.equal(connRef.state, "idle");
|
|
});
|
|
|
|
test("Copy Tab after TERM-SSHD source close falls back to a fresh dial", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t, {
|
|
connectReady: true,
|
|
remoteVer: "TERM-SSHD",
|
|
});
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
|
const sessions = new Map();
|
|
const conn = makeDeferredShellConn();
|
|
conn._remoteVer = "TERM-SSHD";
|
|
const source = makeSourceSession(conn, { hostname: "blj.yd.com.cn", username: "test" });
|
|
const connRef = source.connRef;
|
|
sessions.set("source", source);
|
|
terminalBridge.init({ sessions, electronModule: {} });
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
const startPromise = start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "blj.yd.com.cn",
|
|
username: "test",
|
|
sourceSessionId: "source",
|
|
},
|
|
);
|
|
await new Promise((r) => setImmediate(r));
|
|
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
|
|
assert.equal(connRef.allowShellReuse, false);
|
|
conn.flushShell();
|
|
await startPromise;
|
|
assert.equal(getClientConstructCount(), 1, "must not keep the TERM-SSHD source transport");
|
|
assert.notEqual(sessions.get("copy").conn, conn);
|
|
});
|
|
|
|
test("does not reuse when the source endpoint differs from the requested target", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
// Source connected to the OLD host; the saved host was then edited.
|
|
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
// The duplicate now targets a DIFFERENT hostname. Reusing the old connection
|
|
// would run commands on the wrong machine, so it must connect fresh instead.
|
|
await assert.rejects(
|
|
() => start(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.2", // different host
|
|
username: "alice",
|
|
sourceSessionId: "source",
|
|
},
|
|
),
|
|
);
|
|
assert.equal(sourceConn.openedShells.length, 0, "must not reuse a mismatched connection");
|
|
assert.equal(getClientConstructCount(), 1, "should dial a fresh connection to the new host");
|
|
});
|
|
|
|
test("synchronous shell failure releases the ref and falls back to fresh", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const sourceConn = makeReusableConn();
|
|
// Make conn.shell throw synchronously, as ssh2 does when the transport just
|
|
// dropped (e.g. "Not connected").
|
|
sourceConn.shell = () => { throw new Error("Not connected"); };
|
|
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
|
|
const connRef = source.connRef;
|
|
sessions.set("source", source);
|
|
|
|
const start = registerStartHandler(bridge, sessions);
|
|
const sender = makeSender();
|
|
await assert.rejects(
|
|
() => start(
|
|
{ sender },
|
|
{ sessionId: "copy", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
|
|
),
|
|
);
|
|
// The up-front ref hold must be released so the source's count isn't leaked.
|
|
assert.equal(connRef.count, 1, "ref count restored after synchronous shell failure");
|
|
assert.equal(getClientConstructCount(), 1, "should fall back to a fresh connection");
|
|
assert.deepEqual(
|
|
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
|
|
[{ sessionId: "copy", sourceSessionId: "source" }],
|
|
);
|
|
});
|
|
|
|
test("falls back to a fresh connection when the source is gone", async (t) => {
|
|
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
|
|
const sessions = new Map();
|
|
const otherConn = makeReusableConn();
|
|
sessions.set("other", makeSourceSession(otherConn, { hostname: "10.0.0.1", username: "alice" }));
|
|
const start = registerStartHandler(bridge, sessions);
|
|
|
|
// sourceSessionId points at a session that doesn't exist -> fresh connect.
|
|
// The mocked client emits an error on connect, so the start call rejects;
|
|
// the important assertion is that a fresh connection was attempted.
|
|
const sender = makeSender();
|
|
await assert.rejects(
|
|
() => start(
|
|
{ sender },
|
|
{
|
|
sessionId: "copy",
|
|
hostname: "10.0.0.1",
|
|
username: "alice",
|
|
sourceSessionId: "missing-source",
|
|
},
|
|
),
|
|
);
|
|
assert.equal(otherConn.openedShells.length, 0, "must not substitute another matching transport");
|
|
assert.equal(getClientConstructCount(), 1, "should attempt one fresh connection");
|
|
assert.deepEqual(
|
|
getConnectionReuseFallbackEvents(sender).map((m) => m.payload),
|
|
[{ sessionId: "copy", sourceSessionId: "missing-source" }],
|
|
);
|
|
});
|