mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
This commit is contained in:
1 parent
44062e30d1
commit
ffa872ebd3
19 files changed
+270
-292
No files matched your search
@@ -1,64 +0,0 @@
|
||||
import { Duplex } from "node:stream";
|
||||
import type { RawData, WebSocket } from "ws";
|
||||
|
||||
export function waitForWebSocketOpen(
|
||||
socket: WebSocket,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.off("open", onOpen);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
reject(new Error("Cloudflare tunnel timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
socket.once("open", onOpen);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
export function createWebSocketDuplex(socket: WebSocket): Duplex {
|
||||
const duplex = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
try {
|
||||
socket.send(chunk, (error) => callback(error || undefined));
|
||||
} catch (error) {
|
||||
callback(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
},
|
||||
destroy(error, callback) {
|
||||
cleanup();
|
||||
socket.terminate();
|
||||
callback(error);
|
||||
},
|
||||
});
|
||||
|
||||
const onMessage = (data: RawData) => duplex.push(data);
|
||||
const onClose = () => duplex.destroy();
|
||||
const onError = () => duplex.destroy();
|
||||
const cleanup = () => {
|
||||
socket.off("message", onMessage);
|
||||
socket.off("close", onClose);
|
||||
socket.off("error", onError);
|
||||
};
|
||||
|
||||
socket.on("message", onMessage);
|
||||
socket.on("close", onClose);
|
||||
socket.on("error", onError);
|
||||
return duplex;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a
|
||||
* jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
|
||||
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||
* proxy, in that order. Sets config.sock and
|
||||
* returns the jump client so the caller can close it with the connection.
|
||||
*/
|
||||
|
||||
@@ -39,7 +39,7 @@ function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
|
||||
class SshTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
|
||||
readonly stage: "jump-host" | "jump-forward" | "proxy",
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(message, options);
|
||||
@@ -50,8 +50,6 @@ class SshTransportError extends Error {
|
||||
export interface OpenTransportOptions {
|
||||
/** Knock before connecting when the host has a sequence. Default true. */
|
||||
portKnock?: boolean;
|
||||
/** Use terminalConfig.cfAccessClient* when present. Default true. */
|
||||
cloudflare?: boolean;
|
||||
/** Resolve DNS up front for a direct connection. Default true. */
|
||||
resolveDns?: boolean;
|
||||
log?: SshAuthLog;
|
||||
@@ -59,7 +57,7 @@ export interface OpenTransportOptions {
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump" | "cloudflare";
|
||||
via: "direct" | "proxy" | "jump";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
@@ -94,8 +92,6 @@ export async function openSshTransport(
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
const log = options.log ?? (() => {});
|
||||
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
@@ -113,42 +109,6 @@ export async function openSshTransport(
|
||||
|
||||
let via: OpenedTransport["via"] = "direct";
|
||||
|
||||
const terminalConfig = (host.terminalConfig ?? {}) as Record<string, unknown>;
|
||||
if (
|
||||
options.cloudflare !== false &&
|
||||
terminalConfig.cfAccessClientId &&
|
||||
terminalConfig.cfAccessClientSecret
|
||||
) {
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const { createWebSocketDuplex, waitForWebSocketOpen } =
|
||||
await import("../cloudflare-websocket.js");
|
||||
const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip;
|
||||
const cfWs = new WebSocket(
|
||||
`wss://${cfHostname}/cdn-cgi/access/ssh-connect`,
|
||||
{
|
||||
headers: {
|
||||
"CF-Access-Client-Id": terminalConfig.cfAccessClientId as string,
|
||||
"CF-Access-Client-Secret":
|
||||
terminalConfig.cfAccessClientSecret as string,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitForWebSocketOpen(cfWs, 30000);
|
||||
config.sock = createWebSocketDuplex(
|
||||
cfWs,
|
||||
) as unknown as typeof config.sock;
|
||||
via = "cloudflare";
|
||||
log("info", "Connected via Cloudflare Tunnel");
|
||||
} catch (error) {
|
||||
throw new SshTransportError(
|
||||
"Cloudflare tunnel connection failed: " + getErrorMessage(error),
|
||||
"cloudflare",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const jumpUserId = host.userId || "";
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
|
||||
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HostSessionStatus } from "./host-session-status.js";
|
||||
|
||||
describe("HostSessionStatus", () => {
|
||||
it("reports only the first connection and last disconnection per host", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const closeFirst = status.register(7);
|
||||
const closeSecond = status.register(7);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
closeFirst();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes connection cleanup idempotent", () => {
|
||||
const status = new HostSessionStatus();
|
||||
const listener = vi.fn();
|
||||
status.subscribe(listener);
|
||||
|
||||
const close = status.register(9);
|
||||
close();
|
||||
close();
|
||||
|
||||
expect(listener.mock.calls).toEqual([
|
||||
[9, true],
|
||||
[9, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
|
||||
describe("getJumpHostSocks5Config", () => {
|
||||
it("uses the first jump host proxy settings", () => {
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy.internal",
|
||||
socks5Port: 1080,
|
||||
socks5Username: "user",
|
||||
socks5Password: "secret",
|
||||
socks5ProxyChain: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use destination proxy settings for the first jump host", () => {
|
||||
expect(getJumpHostSocks5Config({ useSocks5: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a serialized proxy chain from the first jump host", () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
name: "Proxy 1",
|
||||
host: "proxy.internal",
|
||||
port: 1080,
|
||||
type: "socks5" as const,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
getJumpHostSocks5Config({
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: JSON.stringify(chain),
|
||||
}),
|
||||
).toEqual({
|
||||
useSocks5: true,
|
||||
socks5Host: undefined,
|
||||
socks5Port: undefined,
|
||||
socks5Username: undefined,
|
||||
socks5Password: undefined,
|
||||
socks5ProxyChain: chain,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSshKeepalive } from "./ssh-keepalive.js";
|
||||
|
||||
describe("resolveSshKeepalive", () => {
|
||||
it("preserves zero to disable SSH keepalives", () => {
|
||||
expect(resolveSshKeepalive(0, 0, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 0,
|
||||
keepaliveCountMax: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses defaults when keepalive settings are absent", () => {
|
||||
expect(resolveSshKeepalive(undefined, undefined, 60000, 5)).toEqual({
|
||||
keepaliveInterval: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the existing minimums for positive settings", () => {
|
||||
expect(resolveSshKeepalive(1, 0.5, 30000, 5)).toEqual({
|
||||
keepaliveInterval: 5000,
|
||||
keepaliveCountMax: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user