Fix port knocking before SSH connect (#1013)

This commit is contained in:
ZacharyZcR
2026-07-10 08:02:55 +08:00
committed by GitHub
parent 50a6572ac1
commit 11ecb66b33
3 changed files with 123 additions and 13 deletions
+51 -13
View File
@@ -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();
});
});
+15
View File
@@ -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}`, {