mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
180 lines
6.1 KiB
TypeScript
180 lines
6.1 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { readFileSync } from "node:fs";
|
|
import vm from "node:vm";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
const main = readFileSync(
|
|
new URL("../../../../electron/main.cjs", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const section = (from: string, to: string) =>
|
|
main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from)));
|
|
function setup() {
|
|
const sockets: Socket[] = [];
|
|
class Socket extends EventEmitter {
|
|
readyState = 1;
|
|
send = vi.fn();
|
|
close = vi.fn(() => {
|
|
if (this.readyState === 3) return;
|
|
this.readyState = 3;
|
|
this.emit("close");
|
|
});
|
|
constructor() {
|
|
super();
|
|
sockets.push(this);
|
|
queueMicrotask(() => this.emit("open"));
|
|
}
|
|
ready() {
|
|
this.emit(
|
|
"message",
|
|
Buffer.from(JSON.stringify({ type: "ready" })),
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
const runtimes = new Map();
|
|
let incoming!: (socket: EventEmitter) => void;
|
|
const server = Object.assign(new EventEmitter(), {
|
|
listen: (_options: unknown, ready: () => void) => ready(),
|
|
close: vi.fn((done?: () => void) => done?.()),
|
|
});
|
|
const headers = vi.fn(async () => ({}));
|
|
const dynamic = vi.fn();
|
|
const context = vm.createContext({
|
|
setTimeout,
|
|
clearTimeout,
|
|
WebSocket: Socket,
|
|
crypto: { randomUUID: () => "runtime-token" },
|
|
net: {
|
|
createServer: (callback: typeof incoming) => {
|
|
incoming = callback;
|
|
return server;
|
|
},
|
|
},
|
|
c2sTunnelRuntimes: runtimes,
|
|
normalizeC2STunnelAddresses: (t: unknown) => t,
|
|
resolveC2SRemoteSourceHost: async (t: unknown) => t,
|
|
getC2STunnelName: (t: { name: string }) => t.name,
|
|
getC2SLocalAddress: () => "127.0.0.1",
|
|
getC2SRemoteAddress: () => "127.0.0.1",
|
|
getC2SRelayUrl: () => "wss://server/relay",
|
|
getC2SRelayHeaders: headers,
|
|
getWebSocketOptions: (_url: string, options: unknown) => options,
|
|
attachC2SAuth: vi.fn(),
|
|
logToFile: vi.fn(),
|
|
emitC2STunnelStatuses: vi.fn(),
|
|
checkLocalPortAvailable: async () => ({ available: true }),
|
|
handleC2SDynamicConnection: dynamic,
|
|
setC2STunnelStatus: (name: string, status: unknown) => {
|
|
runtimes.get(name).status = status;
|
|
},
|
|
setC2STunnelError: (name: string, error: string) => {
|
|
runtimes.get(name).status = { connected: false, reason: error };
|
|
},
|
|
createC2SFailure: (error: string) => ({ success: false, error }),
|
|
normalizeC2SErrorMessage: (message: string) => message,
|
|
C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired",
|
|
});
|
|
vm.runInContext(
|
|
section("async function openC2SRelay(", "async function testC2SRelay(") +
|
|
section("async function testC2SRelay(", "async function testC2STunnel(") +
|
|
section("async function startC2STunnel(", "function stopAllC2STunnels("),
|
|
context,
|
|
);
|
|
return {
|
|
context,
|
|
runtimes,
|
|
sockets,
|
|
server,
|
|
headers,
|
|
dynamic,
|
|
incoming: () => incoming,
|
|
};
|
|
}
|
|
|
|
describe("client tunnel runtime authentication", () => {
|
|
it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => {
|
|
const { context, runtimes, sockets, server, headers, dynamic, incoming } =
|
|
setup();
|
|
const tunnel = {
|
|
name: "socks",
|
|
mode: "dynamic",
|
|
sourceHostId: 7,
|
|
sourcePort: 1080,
|
|
};
|
|
await context.startC2STunnel(tunnel, 0, "local-auth");
|
|
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
|
const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() });
|
|
incoming()(premature);
|
|
expect(premature.destroy).toHaveBeenCalledOnce();
|
|
expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth");
|
|
expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({
|
|
keepAlive: true,
|
|
tunnelConfig: { sessionId: "runtime-token" },
|
|
});
|
|
sockets[0].ready();
|
|
await vi.waitFor(() =>
|
|
expect(runtimes.get("socks").status.connected).toBe(true),
|
|
);
|
|
expect(sockets[0].close).not.toHaveBeenCalled();
|
|
const client = Object.assign(new EventEmitter(), { destroy: vi.fn() });
|
|
incoming()(client);
|
|
expect(dynamic).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sessionId: "runtime-token" }),
|
|
client,
|
|
"local-auth",
|
|
);
|
|
await context.stopC2STunnel("socks");
|
|
expect(sockets[0].close).toHaveBeenCalledOnce();
|
|
expect(client.destroy).toHaveBeenCalledOnce();
|
|
expect(server.close).toHaveBeenCalledOnce();
|
|
expect(runtimes.size).toBe(0);
|
|
});
|
|
|
|
it("does not retain a socket for a standalone test or a stopped runtime", async () => {
|
|
const { context, sockets } = setup();
|
|
const test = context.testC2SRelay({ name: "test" });
|
|
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
|
sockets[0].ready();
|
|
expect(await test).toEqual({ success: true });
|
|
expect(sockets[0].close).toHaveBeenCalledOnce();
|
|
expect(
|
|
await context.testC2SRelay({ name: "stopped" }, null, null, null, {}),
|
|
).toEqual({ success: false, error: "Tunnel stopped" });
|
|
expect(sockets).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps the tunnel connected when one of its connections fails", async () => {
|
|
const { context, runtimes, sockets } = setup();
|
|
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
|
const local = () =>
|
|
Object.assign(new EventEmitter(), {
|
|
destroy: vi.fn(),
|
|
write: vi.fn(),
|
|
});
|
|
|
|
const first = local();
|
|
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first);
|
|
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
|
first.emit("error", new Error("ECONNRESET"));
|
|
sockets[0].emit(
|
|
"message",
|
|
Buffer.from(JSON.stringify({ type: "error", error: "refused" })),
|
|
false,
|
|
);
|
|
expect(runtimes.get("web").status.connected).toBe(true);
|
|
|
|
await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local());
|
|
await vi.waitFor(() => expect(sockets).toHaveLength(2));
|
|
sockets[1].emit(
|
|
"message",
|
|
Buffer.from(JSON.stringify({ type: "error", error: "expired" })),
|
|
false,
|
|
);
|
|
expect(runtimes.get("web").status).toMatchObject({
|
|
connected: false,
|
|
reason: "expired",
|
|
});
|
|
});
|
|
});
|