Files
NetMesh/electron/bridges/portForwardingBridge.cancel.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

688 lines
19 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const passphraseHandler = require("./passphraseHandler.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const {
beginTransportDial,
failTransportDial,
getTransportStats,
resetSshTransportRegistryForTests,
} = require("./sshConnectionPool.cjs");
const {
startPortForward,
stopPortForward,
stopPortForwardByRuleId,
getPortForwardStatus,
subscribePortForward,
listPortForwards,
cancelTunnel,
publishTunnelStatus,
shouldFinalizeTunnelClose,
isReusableTunnelStatus,
_bindPortForwardChannelsForTests: bindPortForwardChannels,
} = require("./portForwardingBridge.cjs");
function createEncryptedKey(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-port-forward-"));
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
const keyPath = path.join(dir, "id_ed25519");
const result = spawnSync("ssh-keygen", [
"-q",
"-t",
"ed25519",
"-N",
"secret",
"-f",
keyPath,
"-C",
"netcatty-test",
], { encoding: "utf8" });
if (result.status !== 0) {
t.skip("ssh-keygen is unavailable");
return null;
}
return keyPath;
}
function createSender() {
return createCapturingSender();
}
function createCapturingSender(onSend = () => {}, id = 1) {
return {
id,
isDestroyed: () => false,
send: (channel, payload) => onSend(channel, payload),
};
}
test("only live or connecting tunnels are reusable", () => {
assert.equal(isReusableTunnelStatus("active"), true);
assert.equal(isReusableTunnelStatus("connecting"), true);
assert.equal(isReusableTunnelStatus("error"), false);
assert.equal(isReusableTunnelStatus("inactive"), false);
});
test("status publication removes destroyed renderer subscribers", () => {
const received = [];
const destroyed = {
id: 1,
isDestroyed: () => true,
send: () => assert.fail("destroyed renderer must not receive status"),
};
const healthy = createCapturingSender((channel, payload) => {
received.push({ channel, payload });
}, 2);
const tunnel = {
subscribers: new Map([
[destroyed.id, destroyed],
[healthy.id, healthy],
]),
};
publishTunnelStatus("pf-prune-subscribers", tunnel, "active");
assert.deepEqual(Array.from(tunnel.subscribers.keys()), [healthy.id]);
assert.equal(received.length, 1);
assert.equal(received[0]?.payload.status, "active");
});
test("failed active tunnel cleanup publishes an error instead of a false inactive state", async () => {
let wouldPublishDuringCleanup;
const statuses = [];
const tunnel = {
status: "active",
server: {
close() {
throw new Error("server close failed");
},
},
conn: {
end() {
wouldPublishDuringCleanup = shouldFinalizeTunnelClose(tunnel);
},
},
};
await assert.rejects(
() => cancelTunnel("pf-active-cleanup-failure", tunnel, (status, error) => {
statuses.push({ status, error });
}),
/server close failed/,
);
assert.equal(wouldPublishDuringCleanup, false);
assert.equal(shouldFinalizeTunnelClose(tunnel), false);
assert.equal(tunnel.status, "error");
assert.deepEqual(statuses, [{ status: "error", error: "server: server close failed" }]);
});
test("cancelling a tunnel clears its pending keyboard-interactive request", async () => {
const tunnelId = "pf-keyboard-interactive-cancel";
const requestId = keyboardInteractiveHandler.generateRequestId("port-forward");
const finishCalls = [];
keyboardInteractiveHandler.storeRequest(
requestId,
(responses) => finishCalls.push(responses),
1,
tunnelId,
);
await cancelTunnel(tunnelId, {}, () => {});
assert.deepEqual(finishCalls, [[]]);
assert.equal(keyboardInteractiveHandler.getRequests().has(requestId), false);
});
test("stopping a pending remote forward invalidates its connection immediately", async () => {
let finishForward;
let endCalls = 0;
let unforwardCalls = 0;
const conn = {
forwardIn(_bind, _port, callback) {
finishForward = callback;
},
unforwardIn(_bind, _port, callback) {
unforwardCalls += 1;
callback(null);
},
end() { endCalls += 1; },
on() {},
removeListener() {},
};
const tunnelId = "pf-remote-pending-stop";
const tunnel = {
tunnelId,
conn,
status: "connecting",
cancelled: false,
sshTransportManaged: false,
chainConnections: [],
};
const binding = bindPortForwardChannels({
type: "remote",
conn,
tunnelId,
tunnelState: tunnel,
sender: createSender(),
bindAddress: "127.0.0.1",
localPort: 22022,
remoteHost: "127.0.0.1",
remotePort: 22,
chainConnections: [],
sendStatus() {},
});
const stopping = cancelTunnel(tunnelId, tunnel, () => {});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(unforwardCalls, 0);
assert.equal(endCalls, 1, "cancel must invalidate the transport that owns the pending callback");
finishForward(null);
assert.deepEqual(await binding, { tunnelId, success: false, cancelled: true });
await stopping;
assert.equal(unforwardCalls, 0, "a late response on an invalidated transport must be ignored");
assert.equal(endCalls, 1, "cleanup must not end the invalidated connection twice");
});
test("stopping a remote forward whose bind callback hangs discards the connection", async () => {
let endCalls = 0;
let unforwardCalls = 0;
const conn = {
forwardIn() {},
unforwardIn() { unforwardCalls += 1; },
end() { endCalls += 1; },
on() {},
removeListener() {},
};
const tunnelId = "pf-remote-hung-bind-stop";
const tunnel = {
tunnelId,
conn,
status: "connecting",
cancelled: false,
sshTransportManaged: false,
chainConnections: [],
_remoteForwardStartCleanupTimeoutMs: 5,
};
const binding = bindPortForwardChannels({
type: "remote",
conn,
tunnelId,
tunnelState: tunnel,
sender: createSender(),
bindAddress: "127.0.0.1",
localPort: 22023,
remoteHost: "127.0.0.1",
remotePort: 22,
chainConnections: [],
sendStatus() {},
});
await Promise.race([
cancelTunnel(tunnelId, tunnel, () => {}),
new Promise((_, reject) => setTimeout(() => reject(new Error("cancel did not settle")), 100)),
]);
assert.equal(unforwardCalls, 0);
assert.equal(endCalls, 1);
// The bind request itself remains pending because the fake server never
// calls back; it must not keep the test process alive.
void binding.catch(() => {});
});
test("port forwarding can be stopped while waiting for a key passphrase", async (t) => {
const keyPath = createEncryptedKey(t);
if (!keyPath) return;
const privateKey = fs.readFileSync(keyPath, "utf8");
const sent = [];
let passphraseRequest;
const promptStarted = new Promise((resolve) => {
passphraseRequest = resolve;
});
const tunnelId = "pf-rule-cancel-1";
const event = {
sender: createCapturingSender((channel, payload) => {
sent.push({ channel, payload });
if (channel === "netcatty:passphrase-request") {
passphraseRequest(payload);
}
}),
};
const startPromise = startPortForward(event, {
ruleId: "rule-cancel-1",
tunnelId,
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "example.test",
username: "alice",
privateKey,
keyId: "key-1",
});
const request = await promptStarted;
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "connecting",
type: "local",
});
assert.deepEqual(await listPortForwards(), [{
ruleId: "rule-cancel-1",
tunnelId,
type: "local",
status: "connecting",
}]);
const adoptedStatuses = [];
const adoptedEvent = {
sender: createCapturingSender((channel, payload) => {
if (channel === "netcatty:portforward:status") adoptedStatuses.push(payload);
}, 2),
};
assert.deepEqual(await subscribePortForward(adoptedEvent, { tunnelId }), {
tunnelId,
status: "connecting",
type: "local",
});
assert.deepEqual(await stopPortForward(event, { tunnelId }), {
tunnelId,
success: true,
});
assert.equal(adoptedStatuses.length, 1);
assert.equal(adoptedStatuses[0].tunnelId, tunnelId);
assert.equal(adoptedStatuses[0].status, "inactive");
assert.equal(adoptedStatuses[0].error, null);
assert.equal(adoptedStatuses[0].ruleId, "rule-cancel-1");
assert.equal(typeof adoptedStatuses[0].epoch, "string");
assert.equal(typeof adoptedStatuses[0].revision, "number");
assert.equal(adoptedStatuses[0].cleanupRequired, false);
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "inactive",
});
assert.deepEqual(await startPromise, {
tunnelId,
success: false,
cancelled: true,
});
assert.ok(sent.some((event) =>
event.channel === "netcatty:passphrase-cancelled" &&
event.payload.requestId === request.requestId
));
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "inactive",
});
});
test("port forwarding can be stopped while waiting for a matching shared transport dial", async (t) => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
const publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey";
const endpoint = {
hostId: "host-shared-wait",
hostname: "shared-wait.test",
port: 22,
username: "alice",
jumpHosts: [],
proxy: null,
authType: "publickey",
keyId: "key-shared-wait",
certificate: "",
requiresMfa: false,
verifyHostKeys: false,
useSshAgent: false,
agentForwarding: false,
publicKey,
};
const leader = beginTransportDial(endpoint, { kind: "channel" });
t.after(() => failTransportDial(leader, new Error("test cleanup")));
const tunnelId = "pf-shared-wait";
const event = { sender: createSender() };
const started = startPortForward(event, {
tunnelId,
ruleId: "rule-shared-wait",
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: endpoint.hostname,
hostId: endpoint.hostId,
port: endpoint.port,
username: endpoint.username,
authMethod: endpoint.authType,
keyId: endpoint.keyId,
publicKey,
verifyHostKeys: false,
useSshAgent: false,
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(getTransportStats().pendingDials, 1, "PF joins the matching authenticated identity");
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "connecting",
type: "local",
});
assert.deepEqual(await stopPortForward(event, { tunnelId }), { tunnelId, success: true });
assert.deepEqual(await started, { tunnelId, success: false, cancelled: true });
assert.equal(getTransportStats().pendingDials, 1, "stopping a waiter does not cancel the dial leader");
});
test("port forwarding stops when the key passphrase prompt is cancelled", async (t) => {
const keyPath = createEncryptedKey(t);
if (!keyPath) return;
const privateKey = fs.readFileSync(keyPath, "utf8");
const originalRequestPassphrase = passphraseHandler.requestPassphrase;
t.after(() => {
passphraseHandler.requestPassphrase = originalRequestPassphrase;
});
passphraseHandler.requestPassphrase = async () => ({ cancelled: true });
const tunnelId = "pf-rule-cancel-2";
const event = { sender: createSender() };
const result = await startPortForward(event, {
tunnelId,
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "example.test",
username: "alice",
privateKey,
keyId: "key-1",
});
assert.deepEqual(result, {
tunnelId,
success: false,
cancelled: true,
});
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "inactive",
});
});
test("concurrent starts reuse the existing tunnel for the same rule", async (t) => {
const keyPath = createEncryptedKey(t);
if (!keyPath) return;
const privateKey = fs.readFileSync(keyPath, "utf8");
let promptCount = 0;
let firstPromptStarted;
const promptStarted = new Promise((resolve) => {
firstPromptStarted = resolve;
});
const event = {
sender: createCapturingSender((channel) => {
if (channel === "netcatty:passphrase-request") {
promptCount++;
firstPromptStarted();
}
}),
};
const secondEvent = {
sender: createCapturingSender((channel) => {
if (channel === "netcatty:portforward:status") {
throw new Error("renderer closed during send");
}
}, 2),
};
const thirdWindowEvents = [];
const thirdEvent = {
sender: createCapturingSender((channel, payload) => {
thirdWindowEvents.push({ channel, payload });
}, 3),
};
const payload = {
ruleId: "shared-rule",
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "example.test",
username: "alice",
privateKey,
keyId: "key-shared",
};
const firstStart = startPortForward(event, {
...payload,
tunnelId: "pf-shared-rule-first",
});
await promptStarted;
const secondStart = await startPortForward(secondEvent, {
...payload,
tunnelId: "pf-shared-rule-second",
});
const thirdStart = await startPortForward(thirdEvent, {
...payload,
tunnelId: "pf-shared-rule-third",
});
assert.deepEqual(secondStart, {
tunnelId: "pf-shared-rule-first",
success: true,
reused: true,
status: "connecting",
});
assert.deepEqual(thirdStart, {
tunnelId: "pf-shared-rule-first",
success: true,
reused: true,
status: "connecting",
});
assert.equal(promptCount, 1);
assert.deepEqual(await listPortForwards(), [{
ruleId: "shared-rule",
tunnelId: "pf-shared-rule-first",
type: "local",
status: "connecting",
}]);
assert.equal((await stopPortForwardByRuleId(event, { ruleId: "shared-rule" })).stopped, 1);
assert.ok(thirdWindowEvents.some(({ channel, payload }) => (
channel === "netcatty:portforward:status" &&
payload.tunnelId === "pf-shared-rule-first" &&
payload.status === "inactive"
)));
assert.equal((await firstStart).cancelled, true);
});
test("stop by rule id only cancels the matching passphrase prompt", async (t) => {
const keyPath = createEncryptedKey(t);
if (!keyPath) return;
const privateKey = fs.readFileSync(keyPath, "utf8");
const sent = [];
const requests = [];
let resolveBothPrompts;
const bothPromptsStarted = new Promise((resolve) => {
resolveBothPrompts = resolve;
});
const event = {
sender: createCapturingSender((channel, payload) => {
sent.push({ channel, payload });
if (channel === "netcatty:passphrase-request") {
requests.push(payload);
if (requests.length === 2) {
resolveBothPrompts();
}
}
}),
};
const firstTunnelId = "pf-rule-1";
const secondTunnelId = "pf-rule-long-1";
const firstStart = startPortForward(event, {
ruleId: "rule",
tunnelId: firstTunnelId,
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "first.example",
username: "alice",
privateKey,
keyId: "key-1",
});
const secondStart = startPortForward(event, {
ruleId: "rule-long",
tunnelId: secondTunnelId,
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "second.example",
username: "alice",
privateKey,
keyId: "key-2",
});
await bothPromptsStarted;
const firstRequest = requests.find((request) => request.hostname === "first.example");
const secondRequest = requests.find((request) => request.hostname === "second.example");
assert.ok(firstRequest);
assert.ok(secondRequest);
assert.deepEqual(await stopPortForwardByRuleId(event, { ruleId: "rule" }), {
stopped: 1,
failed: 0,
errors: [],
});
assert.deepEqual(await firstStart, {
tunnelId: firstTunnelId,
success: false,
cancelled: true,
});
assert.ok(sent.some((event) =>
event.channel === "netcatty:passphrase-cancelled" &&
event.payload.requestId === firstRequest.requestId
));
assert.equal(sent.some((event) =>
event.channel === "netcatty:passphrase-cancelled" &&
event.payload.requestId === secondRequest.requestId
), false);
assert.deepEqual(await getPortForwardStatus(event, { tunnelId: secondTunnelId }), {
tunnelId: secondTunnelId,
status: "connecting",
type: "local",
});
assert.deepEqual(await stopPortForward(event, { tunnelId: secondTunnelId }), {
tunnelId: secondTunnelId,
success: true,
});
assert.deepEqual(await secondStart, {
tunnelId: secondTunnelId,
success: false,
cancelled: true,
});
});
test("stop by rule id reports cleanup failures and keeps the tunnel retryable", async (t) => {
const keyPath = createEncryptedKey(t);
if (!keyPath) return;
const privateKey = fs.readFileSync(keyPath, "utf8");
let passphraseRequest;
const promptStarted = new Promise((resolve) => {
passphraseRequest = resolve;
});
const event = {
sender: createCapturingSender((channel, payload) => {
if (channel === "netcatty:passphrase-request") passphraseRequest(payload);
}),
};
const tunnelId = "pf-rule-cleanup-failure";
const startPromise = startPortForward(event, {
ruleId: "cleanup-failure-rule",
tunnelId,
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "cleanup-failure.example",
username: "alice",
privateKey,
keyId: "cleanup-failure-key",
});
await promptStarted;
const originalAbort = AbortController.prototype.abort;
AbortController.prototype.abort = function abortFailure() {
throw new Error("abort failed");
};
t.after(() => {
AbortController.prototype.abort = originalAbort;
});
assert.deepEqual(await stopPortForwardByRuleId(event, { ruleId: "cleanup-failure-rule" }), {
stopped: 0,
failed: 1,
errors: ["passphrase prompt: abort failed"],
});
assert.deepEqual(await getPortForwardStatus(event, { tunnelId }), {
tunnelId,
status: "error",
type: "local",
error: "passphrase prompt: abort failed",
});
assert.deepEqual(await startPortForward(event, {
ruleId: "cleanup-failure-rule",
tunnelId: "pf-rule-cleanup-failure-retry",
type: "local",
localPort: 0,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostname: "cleanup-failure.example",
username: "alice",
privateKey,
keyId: "cleanup-failure-key",
}), {
tunnelId,
success: false,
blockedByCleanup: true,
error: "The existing tunnel could not be cleaned up. Stop it successfully before restarting.",
});
AbortController.prototype.abort = originalAbort;
assert.deepEqual(await stopPortForwardByRuleId(event, { ruleId: "cleanup-failure-rule" }), {
stopped: 1,
failed: 0,
errors: [],
});
assert.deepEqual(await startPromise, {
tunnelId,
success: false,
cancelled: true,
});
});