diff --git a/src/backend/ssh/terminal-auth-helpers.ts b/src/backend/ssh/terminal-auth-helpers.ts index 786a6a3a..e34bc91e 100644 --- a/src/backend/ssh/terminal-auth-helpers.ts +++ b/src/backend/ssh/terminal-auth-helpers.ts @@ -8,6 +8,19 @@ import ssh2Pkg, { } from "ssh2"; const { BaseAgent } = ssh2Pkg; +const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000; + +type Sleep = (ms: number) => Promise; + +type PortKnockingOptions = { + tcpTimeoutMs?: number; + udpTimeoutMs?: number; + createTcpSocket?: () => net.Socket; + createUdpSocket?: () => dgram.Socket; + wait?: Sleep; +}; + +const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export class MemoryAgent extends BaseAgent { private key: ParsedKey; @@ -97,34 +110,59 @@ export async function applyAgentAuth( export async function performPortKnocking( host: string, sequence: Array<{ port: number; protocol?: string; delay?: number }>, + options: PortKnockingOptions = {}, ): Promise { + const createTcpSocket = options.createTcpSocket ?? (() => new net.Socket()); + const createUdpSocket = + options.createUdpSocket ?? (() => dgram.createSocket("udp4")); + const wait = options.wait ?? sleep; + const tcpTimeoutMs = options.tcpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS; + const udpTimeoutMs = options.udpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS; + for (const knock of sequence) { - const protocol = knock.protocol || "tcp"; - const delay = knock.delay ?? 100; + const port = Number(knock.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) continue; + + const protocol = (knock.protocol || "tcp").toLowerCase(); + const delay = Number(knock.delay ?? 100); await new Promise((resolve) => { if (protocol === "udp") { - const client = dgram.createSocket("udp4"); - client.send(Buffer.alloc(0), knock.port, host, () => { + const client = createUdpSocket(); + let settled = false; + const timeout = setTimeout(() => finish(), udpTimeoutMs); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); client.close(); resolve(); + }; + client.once("error", finish); + client.send(Buffer.alloc(0), port, host, () => { + finish(); }); } else { - const socket = new net.Socket(); - socket.once("connect", () => { + const socket = createTcpSocket(); + let settled = false; + const timeout = setTimeout(() => finish(), tcpTimeoutMs); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.removeAllListeners("connect"); + socket.removeAllListeners("error"); socket.destroy(); resolve(); - }); - socket.once("error", () => { - socket.destroy(); - resolve(); - }); - socket.connect(knock.port, host); + }; + socket.once("connect", finish); + socket.once("error", finish); + socket.connect(port, host); } }); if (delay > 0) { - await new Promise((r) => setTimeout(r, delay)); + await wait(delay); } } } diff --git a/src/backend/ssh/terminal-port-knocking.test.ts b/src/backend/ssh/terminal-port-knocking.test.ts new file mode 100644 index 00000000..03d3724d --- /dev/null +++ b/src/backend/ssh/terminal-port-knocking.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; +import { performPortKnocking } from "./terminal-auth-helpers.js"; + +class FakeTcpSocket extends EventEmitter { + readonly connect = vi.fn(); + readonly destroy = vi.fn(); +} + +describe("performPortKnocking", () => { + it("continues through TCP knock errors", async () => { + const first = new FakeTcpSocket(); + const second = new FakeTcpSocket(); + const wait = vi.fn().mockResolvedValue(undefined); + + const knocking = performPortKnocking( + "192.0.2.10", + [ + { port: 1111, protocol: "tcp", delay: 10 }, + { port: 2222, protocol: "tcp", delay: 0 }, + ], + { + createTcpSocket: vi + .fn() + .mockReturnValueOnce(first) + .mockReturnValueOnce(second), + wait, + }, + ); + + first.emit("error", new Error("closed")); + await Promise.resolve(); + second.emit("connect"); + await knocking; + + expect(first.connect).toHaveBeenCalledWith(1111, "192.0.2.10"); + expect(second.connect).toHaveBeenCalledWith(2222, "192.0.2.10"); + expect(first.destroy).toHaveBeenCalled(); + expect(second.destroy).toHaveBeenCalled(); + expect(wait).toHaveBeenCalledWith(10); + }); + + it("times out TCP knocks that are silently dropped", async () => { + const socket = new FakeTcpSocket(); + const wait = vi.fn().mockResolvedValue(undefined); + + await performPortKnocking("192.0.2.10", [{ port: 1111, delay: 0 }], { + createTcpSocket: () => socket as never, + tcpTimeoutMs: 1, + wait, + }); + + expect(socket.connect).toHaveBeenCalledWith(1111, "192.0.2.10"); + expect(socket.destroy).toHaveBeenCalled(); + expect(wait).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 31cbb7d6..cb6674c0 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1142,6 +1142,7 @@ wss.on("connection", async (ws: WebSocket, req) => { socks5Username?: string; socks5Password?: string; socks5ProxyChain?: unknown; + portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"]; terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"]; enableSessionLogging?: boolean; }) @@ -1185,6 +1186,20 @@ wss.on("connection", async (ws: WebSocket, req) => { if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) { hostConfig.terminalConfig = resolvedHostData.terminalConfig; } + + if ( + (!hostConfig.portKnockSequence || + hostConfig.portKnockSequence.length === 0) && + resolvedHostData.portKnockSequence && + resolvedHostData.portKnockSequence.length > 0 + ) { + hostConfig.portKnockSequence = resolvedHostData.portKnockSequence; + sendLog( + "port_knock", + "info", + `Loaded ${resolvedHostData.portKnockSequence.length} port knock(s) from server-side host data`, + ); + } } } catch (error) { sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {