mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Fix port knocking before SSH connect (#1013)
This commit is contained in:
@@ -8,6 +8,19 @@ import ssh2Pkg, {
|
||||
} from "ssh2";
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
|
||||
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<void> {
|
||||
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<void>((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<void>((r) => setTimeout(r, delay));
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`, {
|
||||
|
||||
Reference in New Issue
Block a user