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";
|
} from "ssh2";
|
||||||
|
|
||||||
const { BaseAgent } = ssh2Pkg;
|
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 {
|
export class MemoryAgent extends BaseAgent {
|
||||||
private key: ParsedKey;
|
private key: ParsedKey;
|
||||||
@@ -97,34 +110,59 @@ export async function applyAgentAuth(
|
|||||||
export async function performPortKnocking(
|
export async function performPortKnocking(
|
||||||
host: string,
|
host: string,
|
||||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||||
|
options: PortKnockingOptions = {},
|
||||||
): Promise<void> {
|
): 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) {
|
for (const knock of sequence) {
|
||||||
const protocol = knock.protocol || "tcp";
|
const port = Number(knock.port);
|
||||||
const delay = knock.delay ?? 100;
|
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) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (protocol === "udp") {
|
if (protocol === "udp") {
|
||||||
const client = dgram.createSocket("udp4");
|
const client = createUdpSocket();
|
||||||
client.send(Buffer.alloc(0), knock.port, host, () => {
|
let settled = false;
|
||||||
|
const timeout = setTimeout(() => finish(), udpTimeoutMs);
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
client.close();
|
client.close();
|
||||||
resolve();
|
resolve();
|
||||||
|
};
|
||||||
|
client.once("error", finish);
|
||||||
|
client.send(Buffer.alloc(0), port, host, () => {
|
||||||
|
finish();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const socket = new net.Socket();
|
const socket = createTcpSocket();
|
||||||
socket.once("connect", () => {
|
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();
|
socket.destroy();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
};
|
||||||
socket.once("error", () => {
|
socket.once("connect", finish);
|
||||||
socket.destroy();
|
socket.once("error", finish);
|
||||||
resolve();
|
socket.connect(port, host);
|
||||||
});
|
|
||||||
socket.connect(knock.port, host);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (delay > 0) {
|
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;
|
socks5Username?: string;
|
||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: unknown;
|
socks5ProxyChain?: unknown;
|
||||||
|
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
|
||||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||||
enableSessionLogging?: boolean;
|
enableSessionLogging?: boolean;
|
||||||
})
|
})
|
||||||
@@ -1185,6 +1186,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||||
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) {
|
} catch (error) {
|
||||||
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
|
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user