416 lines
11 KiB
JavaScript
416 lines
11 KiB
JavaScript
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const net = require("node:net");
|
||
|
|
const { setTimeout: delay } = require("node:timers/promises");
|
||
|
|
|
||
|
|
const terminalBridge = require("./terminalBridge.cjs");
|
||
|
|
const { FLOW_HIGH_WATER_MARK } = require("./terminalFlowAck.cjs");
|
||
|
|
const { IAC, WILL, WONT, DO, OPT } = require("./telnetProtocol.cjs");
|
||
|
|
|
||
|
|
function listen(server) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
server.once("error", reject);
|
||
|
|
server.listen(0, "127.0.0.1", () => {
|
||
|
|
server.off("error", reject);
|
||
|
|
resolve(server.address().port);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function waitFor(predicate, timeoutMs = 1000) {
|
||
|
|
const startedAt = Date.now();
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const tick = () => {
|
||
|
|
if (predicate()) {
|
||
|
|
resolve();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (Date.now() - startedAt > timeoutMs) {
|
||
|
|
reject(new Error("Timed out waiting for telnet echo event"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setTimeout(tick, 10);
|
||
|
|
};
|
||
|
|
tick();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let nextSessionId = 0;
|
||
|
|
|
||
|
|
async function runEchoNegotiationTest({ auth = {}, command }) {
|
||
|
|
const sessionId = `telnet-echo-test-${nextSessionId++}`;
|
||
|
|
const sockets = new Set();
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
setImmediate(() => {
|
||
|
|
socket.write(Buffer.from([IAC, command, OPT.ECHO]));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const port = await listen(server);
|
||
|
|
const sessions = new Map();
|
||
|
|
const sentEvents = [];
|
||
|
|
terminalBridge.init({
|
||
|
|
sessions,
|
||
|
|
electronModule: {
|
||
|
|
webContents: {
|
||
|
|
fromId: () => ({
|
||
|
|
send(channel, payload) {
|
||
|
|
sentEvents.push({ channel, payload });
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{
|
||
|
|
sessionId,
|
||
|
|
hostname: "127.0.0.1",
|
||
|
|
port,
|
||
|
|
...auth,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
await waitFor(() => sentEvents.some((evt) => evt.channel === "netcatty:telnet:echo-mode"));
|
||
|
|
return {
|
||
|
|
sessionId,
|
||
|
|
payload: sentEvents.find((evt) => evt.channel === "netcatty:telnet:echo-mode").payload,
|
||
|
|
};
|
||
|
|
} finally {
|
||
|
|
terminalBridge.cleanupAllSessions();
|
||
|
|
for (const socket of sockets) socket.destroy();
|
||
|
|
await new Promise((resolve) => server.close(resolve));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
test("Telnet WONT ECHO does not force local echo for no-auth sessions", async () => {
|
||
|
|
const { sessionId, payload } = await runEchoNegotiationTest({ command: WONT });
|
||
|
|
|
||
|
|
assert.deepEqual(payload, {
|
||
|
|
sessionId,
|
||
|
|
remoteEcho: false,
|
||
|
|
localEcho: false,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Telnet WILL ECHO disables local echo for no-auth sessions", async () => {
|
||
|
|
const { sessionId, payload } = await runEchoNegotiationTest({ command: WILL });
|
||
|
|
|
||
|
|
assert.deepEqual(payload, {
|
||
|
|
sessionId,
|
||
|
|
remoteEcho: true,
|
||
|
|
localEcho: false,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Telnet WONT ECHO does not force local echo for credential sessions", async () => {
|
||
|
|
const { sessionId, payload } = await runEchoNegotiationTest({
|
||
|
|
command: WONT,
|
||
|
|
auth: { username: "admin", password: "secret" },
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(payload, {
|
||
|
|
sessionId,
|
||
|
|
remoteEcho: false,
|
||
|
|
localEcho: false,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Telnet DO ECHO enables client local echo", async () => {
|
||
|
|
const { sessionId, payload } = await runEchoNegotiationTest({ command: DO });
|
||
|
|
|
||
|
|
assert.deepEqual(payload, {
|
||
|
|
sessionId,
|
||
|
|
remoteEcho: true,
|
||
|
|
localEcho: true,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("replaced Telnet sockets close without affecting the replacement session", async () => {
|
||
|
|
const sessionId = `telnet-echo-test-${nextSessionId++}`;
|
||
|
|
const sockets = new Set();
|
||
|
|
let oldSocket = null;
|
||
|
|
let newSocket = null;
|
||
|
|
let resolveOldClosed = () => {};
|
||
|
|
const oldClosed = new Promise((resolve) => {
|
||
|
|
resolveOldClosed = resolve;
|
||
|
|
});
|
||
|
|
const oldServer = net.createServer((socket) => {
|
||
|
|
oldSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => {
|
||
|
|
sockets.delete(socket);
|
||
|
|
resolveOldClosed();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
const newServer = net.createServer((socket) => {
|
||
|
|
newSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
});
|
||
|
|
|
||
|
|
const oldPort = await listen(oldServer);
|
||
|
|
const newPort = await listen(newServer);
|
||
|
|
const sessions = new Map();
|
||
|
|
const sentEvents = [];
|
||
|
|
terminalBridge.init({
|
||
|
|
sessions,
|
||
|
|
electronModule: {
|
||
|
|
webContents: {
|
||
|
|
fromId: () => ({
|
||
|
|
send(channel, payload) {
|
||
|
|
sentEvents.push({ channel, payload });
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port: oldPort },
|
||
|
|
);
|
||
|
|
await waitFor(() => oldSocket);
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port: newPort },
|
||
|
|
);
|
||
|
|
await waitFor(() => newSocket);
|
||
|
|
await oldClosed;
|
||
|
|
|
||
|
|
assert.equal(sessions.has(sessionId), true);
|
||
|
|
assert.equal(sentEvents.some((evt) => evt.channel === "netcatty:exit"), false);
|
||
|
|
assert.equal(sentEvents.some((evt) => evt.channel === "netcatty:telnet:echo-mode"), false);
|
||
|
|
|
||
|
|
newSocket.write(Buffer.from([IAC, WONT, OPT.ECHO]));
|
||
|
|
await waitFor(() => sentEvents.some((evt) => evt.channel === "netcatty:telnet:echo-mode"));
|
||
|
|
assert.deepEqual(sentEvents.find((evt) => evt.channel === "netcatty:telnet:echo-mode").payload, {
|
||
|
|
sessionId,
|
||
|
|
remoteEcho: false,
|
||
|
|
localEcho: false,
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
terminalBridge.cleanupAllSessions();
|
||
|
|
for (const socket of sockets) socket.destroy();
|
||
|
|
await new Promise((resolve) => oldServer.close(resolve));
|
||
|
|
await new Promise((resolve) => newServer.close(resolve));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("replacing a Telnet session discards buffered output from the old session", async () => {
|
||
|
|
const sessionId = `telnet-echo-test-${nextSessionId++}`;
|
||
|
|
const sockets = new Set();
|
||
|
|
let newSocket = null;
|
||
|
|
const newServer = net.createServer((socket) => {
|
||
|
|
newSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
});
|
||
|
|
|
||
|
|
const newPort = await listen(newServer);
|
||
|
|
const sessions = new Map();
|
||
|
|
const sentEvents = [];
|
||
|
|
let discarded = 0;
|
||
|
|
let flushed = 0;
|
||
|
|
let destroyed = 0;
|
||
|
|
let cancelled = 0;
|
||
|
|
let released = 0;
|
||
|
|
|
||
|
|
sessions.set(sessionId, {
|
||
|
|
type: "telnet-native",
|
||
|
|
socket: {
|
||
|
|
destroy() {
|
||
|
|
destroyed += 1;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
zmodemSentry: {
|
||
|
|
cancel() {
|
||
|
|
cancelled += 1;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
discardPendingData() {
|
||
|
|
discarded += 1;
|
||
|
|
},
|
||
|
|
flushPendingData() {
|
||
|
|
flushed += 1;
|
||
|
|
},
|
||
|
|
releaseTelnetGeneration() {
|
||
|
|
released += 1;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
terminalBridge.init({
|
||
|
|
sessions,
|
||
|
|
electronModule: {
|
||
|
|
webContents: {
|
||
|
|
fromId: () => ({
|
||
|
|
send(channel, payload) {
|
||
|
|
sentEvents.push({ channel, payload });
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port: newPort },
|
||
|
|
);
|
||
|
|
await waitFor(() => newSocket);
|
||
|
|
|
||
|
|
assert.equal(discarded, 1);
|
||
|
|
assert.equal(flushed, 0);
|
||
|
|
assert.equal(destroyed, 1);
|
||
|
|
assert.equal(cancelled, 1);
|
||
|
|
assert.equal(released, 1);
|
||
|
|
assert.equal(sentEvents.some((evt) => evt.channel === "netcatty:data"), false);
|
||
|
|
assert.equal(sessions.get(sessionId)?.type, "telnet-native");
|
||
|
|
} finally {
|
||
|
|
terminalBridge.cleanupAllSessions();
|
||
|
|
for (const socket of sockets) socket.destroy();
|
||
|
|
await new Promise((resolve) => newServer.close(resolve));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Telnet paced backlog records buffered pressure while draining", async () => {
|
||
|
|
const sessionId = `telnet-echo-test-${nextSessionId++}`;
|
||
|
|
const sockets = new Set();
|
||
|
|
let serverSocket = null;
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
serverSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
});
|
||
|
|
|
||
|
|
const port = await listen(server);
|
||
|
|
const sessions = new Map();
|
||
|
|
const sentEvents = [];
|
||
|
|
terminalBridge.init({
|
||
|
|
sessions,
|
||
|
|
electronModule: {
|
||
|
|
webContents: {
|
||
|
|
fromId: () => ({
|
||
|
|
send(channel, payload) {
|
||
|
|
sentEvents.push({ channel, payload });
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port },
|
||
|
|
);
|
||
|
|
await waitFor(() => serverSocket);
|
||
|
|
|
||
|
|
const session = sessions.get(sessionId);
|
||
|
|
session.flowState = {
|
||
|
|
rendererPaused: true,
|
||
|
|
unackedBytes: 0,
|
||
|
|
bufferedBytes: 0,
|
||
|
|
appliedPause: false,
|
||
|
|
outputPaused: true,
|
||
|
|
};
|
||
|
|
const flood = "x".repeat(FLOW_HIGH_WATER_MARK + 10);
|
||
|
|
serverSocket.write(flood);
|
||
|
|
|
||
|
|
await waitFor(() => (sessions.get(sessionId)?.flowState?.bufferedBytes || 0) > 0);
|
||
|
|
let flowState = sessions.get(sessionId)?.flowState;
|
||
|
|
assert.equal(flowState?.appliedPause, true);
|
||
|
|
assert.deepEqual(sentEvents.filter((evt) => evt.channel === "netcatty:data"), []);
|
||
|
|
|
||
|
|
terminalBridge.setSessionFlowPaused(
|
||
|
|
{ sender: {} },
|
||
|
|
{ sessionId, paused: false },
|
||
|
|
);
|
||
|
|
|
||
|
|
await waitFor(() => sentEvents.some((evt) => evt.channel === "netcatty:data"));
|
||
|
|
flowState = sessions.get(sessionId)?.flowState;
|
||
|
|
assert.equal(flowState?.rendererPaused, false);
|
||
|
|
} finally {
|
||
|
|
terminalBridge.cleanupAllSessions();
|
||
|
|
for (const socket of sockets) socket.destroy();
|
||
|
|
await new Promise((resolve) => server.close(resolve));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("replacing a Telnet session cancels delayed automated writes from the old socket", async () => {
|
||
|
|
const sessionId = `telnet-echo-test-${nextSessionId++}`;
|
||
|
|
const sockets = new Set();
|
||
|
|
const oldChunks = [];
|
||
|
|
const newChunks = [];
|
||
|
|
let oldSocket = null;
|
||
|
|
let newSocket = null;
|
||
|
|
const oldServer = net.createServer((socket) => {
|
||
|
|
oldSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
socket.on("data", (buf) => oldChunks.push(buf));
|
||
|
|
});
|
||
|
|
const newServer = net.createServer((socket) => {
|
||
|
|
newSocket = socket;
|
||
|
|
sockets.add(socket);
|
||
|
|
socket.on("error", () => {});
|
||
|
|
socket.on("close", () => sockets.delete(socket));
|
||
|
|
socket.on("data", (buf) => newChunks.push(buf));
|
||
|
|
});
|
||
|
|
|
||
|
|
const oldPort = await listen(oldServer);
|
||
|
|
const newPort = await listen(newServer);
|
||
|
|
const sessions = new Map();
|
||
|
|
terminalBridge.init({
|
||
|
|
sessions,
|
||
|
|
electronModule: {
|
||
|
|
webContents: {
|
||
|
|
fromId: () => ({ send() {} }),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port: oldPort },
|
||
|
|
);
|
||
|
|
await waitFor(() => oldSocket);
|
||
|
|
|
||
|
|
terminalBridge.writeToSession(
|
||
|
|
{},
|
||
|
|
{
|
||
|
|
sessionId,
|
||
|
|
data: "old-one\rold-two\r",
|
||
|
|
automated: true,
|
||
|
|
lineDelayMs: 80,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
await waitFor(() => Buffer.concat(oldChunks).includes(Buffer.from("old-one\r\n")));
|
||
|
|
|
||
|
|
await terminalBridge.startTelnetSession(
|
||
|
|
{ sender: { id: 1 } },
|
||
|
|
{ sessionId, hostname: "127.0.0.1", port: newPort },
|
||
|
|
);
|
||
|
|
await waitFor(() => newSocket);
|
||
|
|
await delay(140);
|
||
|
|
|
||
|
|
assert.equal(Buffer.concat(newChunks).includes(Buffer.from("old-two")), false);
|
||
|
|
} finally {
|
||
|
|
terminalBridge.cleanupAllSessions();
|
||
|
|
for (const socket of sockets) socket.destroy();
|
||
|
|
await new Promise((resolve) => oldServer.close(resolve));
|
||
|
|
await new Promise((resolve) => newServer.close(resolve));
|
||
|
|
}
|
||
|
|
});
|